Compare commits

..
Author SHA1 Message Date
Frank 6215f1a6c8 docs(vault): halogen has an NPU lane on the pin we already run, and it is greedy-only
- frank-dragoon: measured the response_format contract. json_schema and json_object answer
  schema-valid JSON at temperature 0 and draw a 400 at any sampled temperature, and because
  the sampler default is server-side an absent temperature is sampled. The page claimed
  "response_format is unsupported", which sends the next agent past a working feature.
- frank-dragoon: new "The NPU lane" section. The pinned 0.16.2 image already ships the
  five-program AIE catalog including qwen3-embedding-0.6b, so /v1/embeddings is one env var
  plus the accel device, the distro-XRT mount and the fabric-clock hold; embeddinggemma-2
  needs 0.18.3+, the stock-kernel-driver memory-budget fix 0.18.8+. Host census recorded:
  no /opt/xilinx/xrt, distro XRT 1:2.21.75 with libxrt_driver_xdna, clocks unheld.
- Open Notebook: scriptwriter probe verdict — greedy-only, and open-notebook's model record
  is {credential, name, provider, type} with no per-row config, so it cannot ask for greedy;
  frank-archon stays the writer. The embedding gap now has a named door instead of "build a
  new llama.cpp slot".
- Frank TTS: "the NPU is not a door at all" was too broad. It is not a door for the TTS
  family; halogen's precompiled catalog is a door on the same device.
- System Specs: NPU runtime row corrected to the distro XRT actually installed, and the
  stale "frank-zealot serves 27B" line retired in favour of [[frank-zealot]] as sole owner.

Tests: ./tests/run.sh, 455 passed.
2026-10-19 16:40:48 -07:00
Frank 69739237c8 docs(open-notebook): record the deployed shape and what the first render proved
The page described an undeployed service whose scriptwriter was going to be
frank-zealot. Both halves are now false, and the reason is worth more than the
correction.

- Status, ports, coordinates: deployed 2026-10-19, guarded vhost, API published
  on loopback only. The first episode is the proof: 747 s of speech in 301 s of
  wall clock (2.5x real time, knobs at 1), download route 200 with the bearer
  and 401 without.
- New section for the guard chain: provider-before-route, one upstream because
  the image rewrites /api/* itself, and the API_URL law — autodetect appends
  :5055, which is seerr's port on this host, so an unset API_URL yields a UI
  that loads and an app that cannot call itself.
- Provisioned records table (credentials, model rows, dank-duo, dank-daily) and
  the two contract traps measured on the live API: generate takes profile
  NAMES, and the fleet has no /v1/embeddings, so every note save fails its
  embed_note command while renders from content work fine.
- Why frank-archon writes the scripts: frank-zealot is reachable from this
  netns only at 169.254.1.2, and open-notebook's URL guard refuses to store a
  link-local base URL with no override. Census lands on [[Container Network
  Namespaces]], which also gains the root-vs-rootless publish rule — a
  root-published port is invisible to a rootless container, so "publish the
  port" was the wrong remedy as written there.
- Frank TTS: the request's model field is ignored; only voice selects a pack.

Tests: ./tests/run.sh, 455 passed.
2026-10-19 16:22:38 -07:00
Frank e5580abb6f feat(open-notebook): put the podcast generator behind the guard chain
The service came up internal-only: UI on the LAN at 8502, API at 5056, both
published on every interface, with only its own shared password in front. That
put the episode library outside the fleet's auth chain and one port scan away
from anything on the network.

- Caddy: guarded vhost notebook.franksdank.cloud (Anubis -> Authentik; provider
  notebook-proxy, internal_host caddy:8017) plus the post-auth route to the
  published UI port. One upstream is enough, because the image's own Next.js
  layer rewrites /api/* to the FastAPI beside it — so the API needs no outward
  face at all, and 5056 moves to loopback, where only the deployer's health gate
  dials it.
- scripts/open-notebook.env: API_URL and CORS_ORIGINS become the single guarded
  origin. Left to itself the app auto-detects <host>:5055, the port seerr holds
  on this host; a LAN-origin API_URL would also take the browser around the
  guard chain, where its authenticated session does not ride.
- addn-hosts: the LAN A record. The homepage card goes live and links the vhost.
- Seam: the API origin is derived from whatever the Caddyfile routes to 8502, so
  renaming the vhost moves the test instead of leaving it blessing a stale name,
  and a new test proves the vhost hands the WHOLE request to Anubis.
2026-10-19 16:13:53 -07:00
Frank 933420fd29 fix(open-notebook): the database probe has to be exec-form JSON
podman wraps a bare HealthCmd as CMD-SHELL (measured on Dankclaw's podman 5.7:
--health-cmd "/surreal is-ready" stores {"Test":["CMD-SHELL","/surreal is-ready"]}),
and the pinned SurrealDB v2 image is distroless — no /bin/sh, not even `ls`.
So the probe could never run: every 30 s interval exited 1 in ~30 ms with empty
output while the database was listening and answering, and open-notebook's first
deploy died at the gate ("did not become healthy in 90s"). The hand-check
`podman exec … /surreal is-ready` printed OK, which is why the old comment
believed the probe worked.

The JSON array survives the Quadlet generator (--health-cmd "[\"/surreal\",\"is-ready\"]")
and stores ["CMD","/surreal","is-ready"], which execs directly: a throwaway
instance of the pinned digest went healthy on that form. The seam is re-pinned
to the shell-free contract; the old assertion forbade quotes in any HealthCmd,
which is what pinned the broken shape.
2026-10-19 16:07:28 -07:00
dank 9ffa99902d Merge pull request 'fix(frank-archon): the state-dir guard names what the directory is now' (#39) from fix/frank-archon-state-dir-comment into main 2026-10-19 15:02:01 -07:00
Frank b8f564f3bd fix(frank-archon): the state-dir guard names what the directory is now
The refusal message still named "the /data state dir"; /data left with the
prefix disk tier. The serve script mounts /models ro, the rad-tune cache and the
chat template only, and tests/frank_archon_unit.bats asserts no :/data mount
exists, so the line an operator reads at the moment a boot refuses pointed at a
mount that is gone.

Text only. The guard still refuses: the block was run standalone with the
variable unset and every neighbour set - it prints the new message and exits 1.
bash -n clean, ./tests/run.sh green (454 tests).
2026-10-19 15:02:01 -07:00
dank d42d053abe Merge pull request 'docs(frank-archon): reconcile the runbook with the live 1.8.1 two-slot shape' (#38) from docs/frank-archon-181-two-slot into main 2026-10-19 14:56:59 -07:00
Frank 7eed636bd1 docs(perf): destroy §9's vendor absolutes, keep the ratios
The t/s ladder and the TTFT delta triple measure a configuration neither
deployed nor compared; per the purpose test they are dead weight. What survives
is the knob pricing (halving the shared chunk costs roughly a fifth of prefill
throughput), the long-prompt-only shape of the span win, and the method. Run-on
now names the era the census was taken in.
2026-10-19 14:55:43 -07:00
Frank d9b82b372e docs(frank-archon): reconcile the runbook with the live 1.8.1 two-slot shape
The endpoint moved to radiance 1.8.1 with MAX_SEQS=2 and the
--vram-weights-mib/--vram-kv-mib pair removed (PR #30); env, serve script and
bats carried the new shape while the page and the AGENTS.md block still
described the 1.0.13 3-slot era, so every point-of-use number was wrong.

Re-derived from the live checkout and the 2026-10-19 11:10 boot ledger, not
from the stale pre-#31 draft: runtime block (digest, version oracle, the two
CLI breaks that are boot refusals), shape table (demand-signal rows, derived
VRAM split, restored spec/batch row, host-only prefix tier, step sharing),
container contract (no /data mount), the measured 2-slot ledger, a new Eras row
for the retired 1.0.13 era whose rollback is a digest plus a trio rewrite, and
the laws: pool sizing and the per-card budget re-scoped to the pair era, a new
resolver law (funds ~90% of the worst case, host RAM below a slab step is inert,
MAX_SEQS is a demand signal), the sludge law marked measured-at-3-slots with the
fix ordered-not-verified, the knob-availability law flipped, and the slots law
now pricing the third slot in KV (4.20 -> 2.76 GiB).
2026-10-19 14:55:43 -07:00
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
Frank e133d2412d Merge origin/main into docs/radiance-prefill-decode-census
Two conflicts, both from the 2026-10-19 vault compaction on main:

* frontmatter description — main shortened it to satisfy OKF DESC
  (max 120 chars); this branch's longer wording would have failed the
  seam. Kept main's shape and folded the new §9 in by naming
  step-shape attribution instead of profiler attribution.
* the Related bullet for [[vLLM Porting and Compile Triage]] — that
  page was destroyed on main, so the link is dropped rather than
  resurrected; this branch's [[frank-archon]] row is kept.

Index rows regenerated with scripts/gen-vault-index.py.
2026-10-19 14:20:05 -07:00
dank 9971cb559e Merge pull request 'docs(frank-tts): NPU spike verdict + iGPU co-tenancy plan' (#33) from docs/npu-tts-verdict into main 2026-10-19 14:14:46 -07:00
Frank e3a02e25c7 docs(LLM): census the decode stall a prefill causes, and the knobs that turn it
Live step-shape census on frank-archon (radiance 1.0.13, boot of 2026-10-15):
decode-only steps run 18.7 ms, prefill+mixed steps 317 ms — 60 % of engine
wall-clock inside 8 % of the steps — and a day of Prometheus shows decode at
13.3 t/s median while prefill runs >=1000 t/s against 106-240 t/s when it does
not. The lag test says the penalty is confined to the prefill window: no
lingering expert-residency tail, and preemptions are zero.

Adds the method as LLM Performance Diagnosis S9 (counter arithmetic, the lagged
bucket correlation that separates step contention from residency churn, and the
four radiance knobs with their published costs), and the endpoint's own numbers
plus the verdict as two frank-archon laws: --busy-prefill-chunk and
--prefill-interleave are absent from this pin (proven via the running
container's --help), so MNBT is the only crank here and an image bump is gated
by the 1.2.0 numerics dead end.

No behavior change; docs only.
2026-10-19 04:11:20 -07:00
32 changed files with 1036 additions and 270 deletions
+15 -8
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 |
| 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:
[[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
loader ignoring that metadata dequantizes wrong); payload
`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
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,
@@ -458,12 +462,15 @@ disagrees with it is the bug.
- Owns: `scripts/frank-archon.env`, `scripts/serve-frank-archon.sh`,
`scripts/frank-archon.service`, `scripts/ttm-preflight.sh`, template
`scripts/qwen38-froggeric-v22.5.jinja`; image digest-pinned
`stilldeadcode/radiance@sha256:4c3f4845…`; container name = unit name.
- Shape: TP=2 over `wht6`, `--max-num-seqs 3`, `--max-model-len 200000`,
`expert_tiered` placement, 44,928 MiB pinned host pool per rank, VRAM pair
`--vram-weights-mib 25600 --vram-kv-mib 5120` (ONE budget: weights + kv ≤
30,969 MiB/card), `fp8` KV, MTP depth 2, MNBT 2048, prefix cache 8 GiB host
+ 64 GiB disk, n-gram table pinned to RAM.
`stilldeadcode/radiance@sha256:d590e300…` (radiance 1.8.1); container name = unit name.
- Shape: TP=2 over `wht6`, `--max-num-seqs 2`, `--max-model-len 200000`,
`expert_tiered` placement, 44,928 MiB pinned host pool per rank, `fp8` KV,
MTP depth 2, MNBT 2048, `--busy-prefill-chunk auto`, prefix cache 8 GiB host
tier only, n-gram table pinned to RAM. The per-card weights/KV split is derived
by the engine's budget resolver from `--gpu-headroom-mib`, the cache bound and
the host pool; the `--vram-weights-mib`/`--vram-kv-mib` pair and the prefix disk
tier are gone (removed at 1.3.0 / 1.8.1) and naming either is a boot refusal,
not a no-op.
- Harness/gate contract: `models.yml` `contextWindow` MUST quote 200,000
(over-length is refused, never truncated); the ~147 GiB pinned-RAM ledger
is why this is a root unit, and `ttm-preflight.sh` raises
+29
View File
@@ -162,6 +162,25 @@ marreta.franksdank.cloud {
}
}
# --- Open Notebook (guarded: Anubis → Authentik; notebook-proxy provider, internal_host caddy:8017) ---
# The podcast generator. ONE upstream carries the whole app: the image's own
# Next.js layer rewrites /api/* to the FastAPI beside it (INTERNAL_API_URL,
# default http://localhost:5055 inside the container), so a browser on this
# vhost never needs the API port. That is why scripts/open-notebook.env sets
# API_URL to exactly this origin — left unset, the app auto-detects
# `<host-you-opened>:5055`, and 5055 on this host belongs to seerr — and why
# the published API port sits on loopback. App-level login
# (OPEN_NOTEBOOK_PASSWORD) stays on behind the chain: two owners, edge identity
# and the app's own session.
# Dialled by published LAN port, not container name: open-notebook is a
# rootless quadlet and this Caddy lives in root's storage, so root's DNS does
# not see the container (the same reason marreta and SearXNG are dialled by IP).
notebook.franksdank.cloud {
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Pi-hole (quadlet on slirp4netns; published 8083) ---
pihole.franksdank.cloud {
basicauth {
@@ -420,6 +439,16 @@ http://:8017 {
handle @marreta {
reverse_proxy 192.168.0.143:8082
}
@notebook host notebook.franksdank.cloud
handle @notebook {
# Bare proxy, like ai.franksdank.cloud: the app's SSE answers
# (/api/search/ask, chat sessions) stream for minutes through this
# same listener with no flush or timeout tuning, so nothing here
# needs it — a read/write timeout on the route would be the thing
# that cuts them.
reverse_proxy 192.168.0.143:8502
}
}
# --- Optional: Jackett ---
@@ -48,13 +48,22 @@
# without `podman unshare` — accepted for a datastore nobody edits by hand;
# episode AUDIO is a host directory for the opposite reason (see the app unit).
#
# HEALTH. The base image is distroless: no shell, no curl, no wget, so no
# CMD-SHELL probe and no wget/curl probe is possible here (same class of unit
# as loki, whose probe had to move into the image). SurrealDB ships its own
# probe instead — `surreal is-ready` connects to ws://localhost:8000 by default
# and needs no credentials — verified against this digest: it prints `OK` and
# exits 0 while the server is up. Argument-only HealthCmd, like every unit on
# this host, because the quadlet generator splits it on spaces.
# HEALTH. The base image is distroless — measured against this digest, not even
# `ls` exists (`exec: "ls": executable file not found in $PATH`), so there is no
# `/bin/sh`, no curl and no wget. SurrealDB ships its own probe instead:
# `surreal is-ready` connects to ws://localhost:8000 by default, needs no
# credentials, prints `OK` and exits 0 while the server is up.
#
# It must be handed over as a QUADLET-SIDE JSON ARRAY, not a bare command.
# podman wraps a bare `HealthCmd` string as `CMD-SHELL` (measured on this host's
# podman 5.7: `--health-cmd "/surreal is-ready"` stores
# `{"Test":["CMD-SHELL","/surreal is-ready"]}`), and with no `/bin/sh` in the
# image that probe fails in ~30 ms with empty output on every interval while the
# database answers perfectly — which is exactly how this service's first deploy
# died on 2026-10-19 (`did not become healthy in 90s`). The quoted array survives
# the generator (`--health-cmd "[\"/surreal\",\"is-ready\"]"`) and stores
# `["CMD","/surreal","is-ready"]`, which execs directly and goes healthy. Do not
# "simplify" it back to a bare command.
[Unit]
Description=SurrealDB (open-notebook datastore)
@@ -70,7 +79,7 @@ EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
EnvironmentFile=/home/dank/Containers/open-notebook/env
Exec=start --log info rocksdb:/mydata/mydatabase.db
Volume=open-notebook-db:/mydata
HealthCmd=/surreal is-ready
HealthCmd=["/surreal","is-ready"]
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
@@ -33,17 +33,21 @@
# and 5055 is exactly the port seerr already holds on this host
# (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows
# 127.0.0.1:5055 taken). So the API leaves the container on host port 5056.
# That forces one setting: the frontend auto-detects the API as
# `<host-you-opened>:5055`, so API_URL in scripts/open-notebook.env must name
# the remapped port explicitly, or the UI loads and every call fails. Both
# ports are published on all interfaces because the consumer is a browser on
# the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea)
# bind the same way, while loopback-only is reserved for services whose user
# path is the guard chain. This service has NO vhost and no Caddy route: it is
# internal-only until it is guarded, and its own password
# (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not
# decoration: with it unset the API answers everyone on the LAN, including the
# unauthenticated episode-audio download.
# Only 8502 is published on all interfaces, because it is the single upstream
# the guard chain dials: notebook.franksdank.cloud → anubis → authentik →
# http://:8017 → 192.168.0.143:8502 (dankclaw/Caddyfile), and this Caddy lives
# in root's storage, so a published port is the only way it can reach a rootless
# quadlet. The API needs no outward face at all — the image's own Next.js layer
# rewrites /api/* to the FastAPI beside it, so one origin serves UI and API —
# and the fleet's rule is that loopback-only is for services whose user path is
# the guard chain, which this became on 2026-10-19; 5056 stays published on
# loopback because the deployer's health gate dials it there. That is also why
# API_URL in scripts/open-notebook.env names the vhost rather than a port: left
# to itself the frontend auto-detects `<host-you-opened>:5055`, and the UI loads
# while every call fails. OPEN_NOTEBOOK_PASSWORD is not made redundant by the
# chain: it is what keeps the API honest for everything else on this host, and
# with it unset the API answers anyone, including the unauthenticated
# episode-audio download.
#
# VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite
# checkpoints and the extraction caches the image keeps under it
@@ -81,7 +85,7 @@ Pull=never
Network=dankclaw_default
Network=open-notebook.network
PublishPort=0.0.0.0:8502:8502
PublishPort=0.0.0.0:5056:5055
PublishPort=127.0.0.1:5056:5055
EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
EnvironmentFile=/home/dank/Containers/open-notebook/env
Volume=open-notebook-data:/app/data
+1
View File
@@ -45,3 +45,4 @@
192.168.0.143 pihole.franksdank.cloud
192.168.0.143 seerr.franksdank.cloud
192.168.0.143 marreta.franksdank.cloud
192.168.0.143 notebook.franksdank.cloud
+44 -6
View File
@@ -19,11 +19,14 @@
# is recorded in the parent tree, so cloning the parent commit recursively is
# 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 \
# -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 \
# && cmake --build build -j 8
#
@@ -42,8 +45,12 @@
# 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
# 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
# ran on CPU; the box's GPUs belong to the text endpoints).
# CUDA/HIP/METAL/SYCL/RPC stay OFF, VULKAN is ON: the endpoint computes on the
# 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
# 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
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 \
&& apt-get install -y -qq --no-install-recommends \
ca-certificates cmake g++ git make \
libvulkan-dev glslc spirv-headers \
&& rm -rf /var/lib/apt/lists/*
# 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_CUDA=OFF \
-DGGML_HIP=OFF \
-DGGML_VULKAN=OFF \
-DGGML_VULKAN=ON \
-DGGML_METAL=OFF \
-DGGML_SYCL=OFF \
-DGGML_RPC=OFF \
@@ -102,9 +128,21 @@ FROM ${BASE_IMAGE}
# 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
# 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 \
&& 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/*
# 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
# is valid.
#
# MEMORY / NO GPU. Peak RSS measured 5.7 GiB with both GGUFs resident
# (2026-10-18 trial); no memory limit is set because the box has the room and a
# limit would convert a busy moment into an OOM kill mid-utterance. No
# /dev/kfd, no /dev/dri, no keep-groups: every GPU backend is compiled out of
# the image, the endpoint is CPU-only by decision, and the GPUs on this host
# belong to the text endpoints.
# MEMORY / THE RENDER NODE. Host RSS is ~1.7 GiB, not the size of the GGUFs:
# the Vulkan backend copies both tensors into device memory at load, so they
# stop counting in the process's RSS (measured after the 2026-10-19 cutover —
# `podman stats` / `ps -o rss`). No memory limit is set: the box has the room
# and a limit would convert a busy moment into an OOM kill mid-utterance.
#
# `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
# 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
Volume=/home/dank/models/Qwen3-TTS-GGUF:/models: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
HealthInterval=30s
HealthTimeout=10s
+54 -19
View File
@@ -7,7 +7,7 @@
# 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.
#
# 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
# CPU thread count as std::thread::hardware_concurrency()/2
@@ -18,13 +18,19 @@
# placement — pinning is deliberately not done anywhere in
# this stack. See the env file header for the same warning
# 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
# environment still boots the decided configuration: 1.7 B talker Q4_K_M with
# the Q8_0 tokenizer, one request at a time, on the private bridge.
# --device IS here, but conditionally: it reaches the binary only when
# QWENTTS_DEVICE is non-empty, and empty is the CPU rollback — the binary then
# 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
# The binary path. Overridable for one reason: the bats seam runs a stub
@@ -60,13 +66,33 @@ set -euo pipefail
: "${QWENTTS_LANG:=auto}"
# 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
# on a CPU build splits the same cores between two syntheses and the warm RTF
# margin (0.55-0.67 against real time 1.0) disappears. The engine's own
# default is also 1; it is written here because it is a decision, not an
# accident.
# setting, which is what keeps the per-voice timings predictable. A second
# concurrent synthesis shares the device with the first — and on this box that
# device is also carrying frank-dragoon — so it buys no throughput and it
# doubles the wait for the caller behind it. The engine's own default is also 1;
# written here because it is a decision, not an accident.
: "${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
# 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
@@ -79,11 +105,20 @@ for artifact in "$QWENTTS_MODEL" "$QWENTTS_CODEC"; do
fi
done
exec "$QWENTTS_BIN" \
--model "$QWENTTS_MODEL" \
--codec "$QWENTTS_CODEC" \
--alias "$QWENTTS_ALIAS" \
--host "$QWENTTS_HOST" \
--port "$QWENTTS_PORT" \
--lang "$QWENTTS_LANG" \
ARGS=(
--model "$QWENTTS_MODEL"
--codec "$QWENTTS_CODEC"
--alias "$QWENTTS_ALIAS"
--host "$QWENTTS_HOST"
--port "$QWENTTS_PORT"
--lang "$QWENTTS_LANG"
--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
device *name*, or an image that grows an `nvidia_icd.json` silently points TTS at the
3090 slot.
2. **Contention gate.** `frank-dragoon` is the endpoint the operator session runs on, so the
honest measurement with both loaded is dragoon's own TTFT and decode with a synthesis in
flight — one request each, no ladder. Expect compute-unit sharing rather than memory
pressure: the aperture has room, the shaders do not.
2. **Contention gate — PASSED, measured 2026-10-19 after the cutover.** With a 16.9 s
synthesis occupying the device, `frank-dragoon` answered a streamed 124-token request at
**17 ms to first token and ~35 tok/s decode**, against 17 ms and ~33 tok/s for the same
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
`ca-certificates cmake g++ git make` today, and ggml's Vulkan backend compiles its
shaders at build time — the flag alone fails at configure.
2. `-DGGML_VULKAN=OFF` → `ON` in the same cmake block; the flag list is written out
explicitly, so this is a one-word diff.
3. Runtime stage: add `mesa-vulkan-drivers`. Without a Vulkan **ICD inside the image** the
device node buys nothing — there is no `radeon_icd.json` in the container today.
4. `frank-tts-engine.container`: `AddDevice=/dev/dri` + `GroupAdd=keep-groups` — **not**
`renderD128`. On this hybrid box that node is the NVIDIA card; the iGPU is
`renderD129` (`readlink -f /sys/class/drm/renderD129/device/driver` → `amdgpu`).
5. Engine argv: `--device Vulkan0` — qwentts.cpp's tools take `--device <name>` at the
pinned commit.
1. `Containerfile.engine` build stage: add **`libvulkan-dev glslc spirv-headers`**. The
prediction above (`libvulkan-dev glslang-dev`) is wrong on two counts and the configure
says so: ggml looks for `glslc`, which Ubuntu's `glslang-tools` does **not** ship (it
ships `glslangValidator`), and `find_package(SPIRV-Headers CONFIG REQUIRED)` needs the
distro's `spirv-headers`, which does carry the CMake config. `glslc` is its own source
package on noble, at 2023.8 — old enough that only `GL_KHR_cooperative_matrix` compiles,
so the coopmat2 / integer-dot / bf16 / micro-float shader families are built out. Suspect
that compiler before the device if a Vulkan prefill ever reads as slow.
2. `-DGGML_VULKAN=OFF` → `ON`: one word in the written-out flag list.
3. Runtime stage: `mesa-vulkan-drivers` **and** `libvulkan1` — the ICD and its loader. An
image with a loader and no ICD enumerates zero devices.
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
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)
# 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}"
# 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() {
# Refuse rather than restart-loop: a unit whose runtime or payload is
@@ -1484,6 +1490,13 @@ deploy_llm_zealot() {
return 1
fi
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
run_privileged systemctl daemon-reload
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
# 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
# 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
@@ -103,6 +113,17 @@ QWENTTS_LANG=auto
# round-trip outside it) is what absorbs the queue.
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) --------------
# 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
+13 -1
View File
@@ -1,5 +1,6 @@
# 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
# 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
# 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
# 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.
# Id-keyed consumers: OpenWebUI (model row, access_grant, api_configs), models.yml.
ALIAS=frank-zealot
+1 -1
View File
@@ -1,5 +1,5 @@
[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
Wants=network-online.target
+27 -17
View File
@@ -1,5 +1,6 @@
# open-notebook.env — configuration for **open-notebook**, the fleet's podcast
# generator on Dankclaw (UI host port **8502**, API host port **5056**). Read by
# generator on Dankclaw (user path https://notebook.franksdank.cloud; UI host
# port **8502**, API host port **5056 loopback-only**). Read by
# BOTH Quadlet units (dankclaw/open-notebook/quadlet/) as the FIRST
# EnvironmentFile= line, which is why every path and host name below is the
# value the CONTAINER must see.
@@ -47,13 +48,17 @@
#
# THE SCRIPTWRITER. ESPERANTO_LLM_TIMEOUT is the app's own default (180 s), kept
# visible beside the TTS number because both belong to one podcast run: the
# outline call and the transcript call are the slow text work, and frank-zealot
# answers them well inside 180. Provider choice is made in the UI, not here —
# see [[Open Notebook]] for the one-time credential provisioning — and it is
# frank-zealot on purpose: script writing asks for a JSON-schema response, which
# the OpenAI-compatible llama.cpp endpoint is known to serve, while halogen's
# structured-output behaviour on frank-dragoon is UNVERIFIED. halogen remains
# the unproven alternative, not the fallback.
# outline call and the transcript call are the slow text work, and the endpoint
# provisioned today answers them well inside 180. The provider itself is chosen
# in the database, not here — [[Open Notebook]] owns that provisioning and the
# address census behind it. Short version: frank-zealot was the plan (script
# writing asks for a JSON-schema response, which llama.cpp serves and halogen's
# behaviour on frank-dragoon is UNVERIFIED), but no Dankclaw LLM is dialable from
# this container's netns, and the single address that reaches one —
# `host.containers.internal`, 169.254.1.2 — the app refuses to store at all,
# because link-local is a blocked address class. The writer is therefore
# frank-archon (`http://192.168.0.235:7770/v1`), the only fleet LLM that both
# answers from inside this netns and passes that check.
#
# THE DATABASE COORDINATES. SURREAL_URL addresses the sibling container BY NAME
# on systemd-open-notebook (container DNS is scoped to the owning network, so
@@ -73,15 +78,20 @@ SURREAL_PASS=
OPEN_NOTEBOOK_ENCRYPTION_KEY=
OPEN_NOTEBOOK_PASSWORD=
# WHERE THE BROWSER FINDS THE API. The UI auto-detects `<host-you-opened>:5055`,
# and 5055 on this host belongs to seerr, so the API leaves the container on 5056
# and this line has to say so. Both origins are listed in CORS_ORIGINS because
# the API is called cross-origin from the UI's own port: the LAN origin is the
# normal one, the localhost one covers a browser opened on Dankclaw itself. The
# app logs a warning at start while CORS_ORIGINS is unset and defaults to `*`,
# which is a wildcard in front of an endpoint that serves every episode's audio.
API_URL=http://192.168.0.143:5056
CORS_ORIGINS=http://192.168.0.143:8502,http://localhost:8502
# WHERE THE BROWSER FINDS THE API. The guarded vhost
# (notebook.franksdank.cloud → Caddy → 8502) is the only user path, and the
# image's own Next.js layer rewrites `/api/*` to the FastAPI running beside it,
# so ONE origin serves UI and API and API_URL has to name that vhost. This is
# not cosmetic: unset, the app auto-detects `<host-you-opened>:5055` — the port
# upstream ships, and the one seerr holds on this host — and hands the browser
# an address nothing answers; pointed at the LAN `:5056` instead, every call
# leaves the guarded origin and an authenticated browser session does not ride
# it. Same-origin traffic needs no CORS at all, but CORS_ORIGINS stays set to
# the one origin because the app warns at start while it is unset and defaults
# to `*` — a wildcard in front of an endpoint that serves every episode's
# audio. No trailing slash: the UI concatenates `${API_URL}/api/...`.
API_URL=https://notebook.franksdank.cloud
CORS_ORIGINS=https://notebook.franksdank.cloud
# Concurrency: see "THE CPU-SHUTDOWN GUARD" above. Defaults are 5 and 5.
TTS_BATCH_SIZE=1
+1 -1
View File
@@ -45,7 +45,7 @@ set +a
: "${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}"
: "${FRANK_ARCHON_MODEL_DIR:?FRANK_ARCHON_MODEL_DIR must name the .rad dir}"
: "${FRANK_ARCHON_MODEL_FILE:?FRANK_ARCHON_MODEL_FILE must name the .rad file}"
: "${FRANK_ARCHON_STATE_DIR:?FRANK_ARCHON_STATE_DIR must name the /data state dir}"
: "${FRANK_ARCHON_STATE_DIR:?FRANK_ARCHON_STATE_DIR must name the host dir holding the rad-tune cache}"
: "${FRANK_ARCHON_CONTAINER:?FRANK_ARCHON_CONTAINER must name the container}"
: "${FRANK_ARCHON_SERVED_NAME:?FRANK_ARCHON_SERVED_NAME must name the alias}"
: "${FRANK_ARCHON_PORT:?FRANK_ARCHON_PORT must be the port}"
+31
View File
@@ -33,6 +33,19 @@
# what gives this endpoint eyes; MMPROJ_FILE="" is the documented text-only
# 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
# 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
@@ -136,6 +149,13 @@ fi
# Vision projector. No colon on purpose: an explicitly empty value means
# "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}"
# 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
# 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")
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
# answers 501 on /metrics, and the fleet scrape then reports the endpoint DOWN.
if [[ "$METRICS" = "1" ]]; then
@@ -88,8 +88,29 @@ Verified outcomes of that recipe on 2026-09-02: from inside `hermes-dashboard`
published) and `:9119` (its own published port) connect; `:8080` and `:8787`
are **refused instantly** even though both listeners are bound to `0.0.0.0` in
the host netns. Probes to `host.containers.internal` (`169.254.1.2`) from that
network time out rather than refuse — the alias is an address on the *default*
`podman` network, and this container is not on it.
network time out rather than refuse — but see the 2026-10-19 re-measure below,
where the same alias answers from another `dankclaw_default` container.
### 2026-10-19: which published ports a rootless container can actually dial
Probed from inside `open-notebook` (rootless, on `dankclaw_default` **and** the
private `systemd-open-notebook`), each address aimed at a live listener:
| Dial | Result | Mechanism |
|---|---|---|
| `192.168.0.143:3000` (Gitea) | **200** | published by a **rootless** container — rootless egress crosses the host netfilter where netavark installed that DNAT |
| `192.168.0.143:7770` (`frank-dragoon`) | refused | published by a **root** container. Root and rootless Podman carry separate netavark rule sets, so a rootless container's traffic never crosses root's and a root-published port is invisible to it |
| `192.168.0.143:7771` (`frank-zealot`) | refused | host-netns listener, unpublished — the rule above, reconfirmed |
| `10.89.1.1:7771` | refused | the bridge gateway address belongs to the rootlesskit netns, not to the host |
| `host.containers.internal:7771` (`169.254.1.2`) | **200** | the gateway alias is the one address that carries a rootless container to a host-netns listener. Why `hermes-dashboard` timed out on this alias in 2026-09-02 while `open-notebook` answers today is **not established**; the difference to check first is the second network and where the default route lands |
| `192.168.0.235:7770` (`frank-archon`) | **200** | a different machine: plain routing, no DNAT in the path |
The consequence that changes designs: **a rootless container's menu of local model
endpoints is `frank-tts` (container DNS, same network) and `frank-archon` (LAN
address on another host).** Both Dankclaw endpoints are out of reach —
`frank-dragoon` behind root's publish, `frank-zealot` behind the host netns — and
the only address that does reach them, `169.254.1.2`, is link-local, which some
applications refuse to store at all ([[Open Notebook]] meets exactly that refusal).
## Operational rules
@@ -98,9 +119,13 @@ network time out rather than refuse — the alias is an address on the *default*
host — and never the reverse either: the deployer's health probe runs on the
host, so it sees host-netns listeners that the container itself cannot dial.
- To give a bridge-net container access to a host-netns listener, the options
are (a) move it to `Network=host`, or (b) publish the needed port. Option (b)
makes that port reachable from every netns and from the LAN — an exposure
change that needs operator approval before deployment, not a silent fix.
are (a) move it to `Network=host`, or (b) publish the needed port — but
publishing is advertised **only inside the Podman that owns the container**:
a root container's published port stays invisible to a rootless consumer
(2026-10-19 table above), so the fix must be made on the same root/rootless
side as the consumer. Either way it is an exposure change — a published port
also answers on the LAN — and needs operator approval before deployment, not
a silent fix.
- `chrome-devtools-protocol` is intentionally `Network=host` with a loopback-only
bind so the browser tool stays unreachable from the LAN; do not "tidy" it onto
a bridge network — its two consumers (gateway, webui) are host-netns and reach
@@ -1,35 +1,36 @@
---
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:
- dankclaw
- tts
- voice
- quadlet
- cpu
timestamp: 2026-10-18
- vulkan
timestamp: 2026-10-19
---
# 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.
## 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 |
|------|-------|
| 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** |
| 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 |
| 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), `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) |
| 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 |
| 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 |
| 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 |
| 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 |
| 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` |
| 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 |
@@ -37,18 +38,11 @@ timestamp: 2026-10-18
## 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) |
|---|---|---|
| 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 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.
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.
## Three laws this service lives under
## Four 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
@@ -64,10 +58,12 @@ frank-tts-engine-warm: voice narrator1 cold=1402ms warm=1812ms
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.
**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
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/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/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 |
| `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/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 |
@@ -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.
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
| 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 |
| 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 |
| 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 |
+14 -11
View File
@@ -32,7 +32,7 @@ render needs the app installed, not this endpoint.
| --- | --- |
| Host | Dankclaw (`192.168.0.143`), rootless podman, user unit `frank-tts.service` |
| Port | **7772**, published (`PublishPort=7772:7772`) — the rest of the `frank-*` port family is in [[Dankclaw Stack]] |
| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only |
| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only. The request's `model` field is **ignored** (measured 2026-10-19: `voxcpm2` and `tts-1` both synthesize the same way) — the voice comes solely from `voice`, so a consumer that must name a model may name it anything |
| Image | `localhost/franksdank/frank-tts:latest`, built from `dankclaw/voiceservice/Containerfile`, `Pull=never` |
| Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) |
| Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state |
@@ -113,16 +113,19 @@ shows up as a failed synthesis, never as a down probe.
## Where the engine computes
CPU, and only CPU is compiled in: `Containerfile.engine` builds `qwentts.cpp` with
`-DGGML_VULKAN=OFF` written out explicitly, so the container carries `libggml-cpu` and no
other backend. The realistic accelerator is the gfx1151 iGPU behind that flag — plus
`/dev/dri` passthrough, the ComfyUI pattern — on the same payload and the same pinned
commit, and the intended shape is **co-tenancy**: the engine resident on the card beside
`frank-dragoon`, not a handover. Memory is not what decides that; shader contention is, and
the census plus both gates are in `report/tts-npu-spike-verdict-2026-10-19.md` §5.
**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
ioctls, then declines this model family's graph without emitting a single log line. The
The **gfx1151 iGPU**, as a co-tenant beside `frank-dragoon`: the Vulkan build of
`qwentts.cpp` with `--device Vulkan0`, and `/dev/dri` handed to the engine container. That is
the shape since 2026-10-19. The flag, the device name, the render-node trap on this hybrid box
and the one-line CPU rollback are owned by [[Frank TTS Engine]] — read that page before
touching either tenant, because a change to one is a change to both. Co-tenancy was gated with
`frank-dragoon` live on the card (its first token and decode rates unchanged with a synthesis
in flight); both gate recipes are §5 of `report/tts-npu-spike-verdict-2026-10-19.md`.
**The NPU is a door only for a model the vendor has already compiled for it.** XDNA2 runs nothing but
precompiled AIE partition programs, and AMD's own Linux runtime opens `/dev/accel/accel0`, succeeds at the
ioctls, then declines this model family's graph without emitting a single log line. That is why TTS stays on
the GPU — and it is not a general veto on the device: halogen ships its own precompiled catalog (embeddings,
decisions, rerankers) that does run there, which is what [[frank-dragoon]]'s NPU-lane section is about. The
measured record and the model-side contract live in `report/` at the repo root
(`tts-npu-spike-verdict-2026-10-19.md`).
+60 -23
View File
@@ -6,25 +6,26 @@ tags:
- open-notebook
- podcasts
- quadlet
timestamp: 2026-10-18
timestamp: 2026-10-19
---
# Open Notebook
open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-zealot**, the voices are rendered by **frank-tts**, and nothing leaves the LAN.
open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-archon**, the voices are rendered by **frank-tts**, and nothing leaves the LAN.
**It is NOT deployed.** This page documents repo-owned configuration — the Quadlet units, the env file, the deployer path, the homepage card — and the contract those files were written against. On 2026-10-18 no `open-notebook` or `open-notebook-surrealdb` container exists on Dankclaw (`podman ps -a` census, 2026-10-18), and the homepage card ships `wip: "down"` with `showLink: false`, so the card is hidden and reported `wip-down`. Re-censused 2026-10-19 and unchanged: no `*notebook*` unit in `systemctl --user list-unit-files`, nothing installed under `~/.config/containers/systemd/` by that name, no container in `podman ps` — the repo's three quadlet files have never been installed on the host. Deploying it is a deliberate next step, not a pending one.
**Deployed 2026-10-19.** Both units installed and `active`, both containers healthy, SurrealDB migrated to v25 on first boot, and the first episode rendered end to end: a note in, an outline, 23 dialogue turns, 23 clips, and one **747-second MP3 on the host bind, produced in 301 seconds** — about 2.5× real time with every concurrency knob at 1. The download route answers `200 audio/mpeg` with the bearer token and **401 without it**. It is guarded like every other user service — `https://notebook.franksdank.cloud`, Anubis → Authentik → the app's own login — and the API is no longer published on the LAN at all.
**Host:** Dankclaw (rootless Podman, user `dank`) — **Ports:** 8502 UI and 5056 API, published on the LAN, **no vhost and no Caddy route** — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook
**Host:** Dankclaw (rootless Podman, user `dank`) — **User path:** `https://notebook.franksdank.cloud` (guarded) — **Published:** `0.0.0.0:8502` (the only upstream Caddy dials) and `127.0.0.1:5056` (the deployer's health gate, nothing else) — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook
## Facts (verified 2026-10-18 against the pinned images, upstream source, and the live host)
## Facts (images and upstream source verified 2026-10-18; live-host columns re-read 2026-10-19 after deploy)
| Item | Value |
|------|-------|
| App image | `docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212` (`open-notebook.container:79`) — the tag upstream's own `docker-compose.yml:27` pins for the multi-container shape. `v1-latest` is a moving tag, so the manifest-list digest (resolved from the registry 2026-10-18) is what makes this reproducible |
| Database image | `docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723` (`open-notebook-surrealdb.container:66`) — that digest is **SurrealDB 2.7.0** (image built 2026-09-23; measured by running the digest: `surreal version`) |
| Do NOT use | the `-single` tags (`v1-latest-single`). That variant bundles SurrealDB inside the app image and is deprecated upstream; here it would give the host a second, hidden database |
| Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `0.0.0.0:5056:5055` (`open-notebook.container:83-84`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows `127.0.0.1:5055` live, 2026-10-18). The API therefore leaves on 5056, which is what forces `API_URL` (below) |
| Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `127.0.0.1:5056:5055` (`open-notebook.container`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows it live). The API therefore leaves on 5056, and on loopback only: the vhost is its sole user path and the image's own Next.js layer rewrites `/api/*` to it, so nothing outside the container needs it |
| User path | `https://notebook.franksdank.cloud` → `anubis:8923` → post-auth listener `@notebook` → `192.168.0.143:8502` (`dankclaw/Caddyfile`); Authentik provider `notebook-proxy` (`external_host` the vhost with its trailing slash, `internal_host http://caddy:8017`); LAN record in `dankclaw/pihole/addn-hosts` |
| Database port | 8000 inside its own namespace, **never published** (`open-notebook-surrealdb.container` has no `PublishPort`). Upstream publishes `127.0.0.1:8000` for debugging (`docker-compose.yml:18`); this unit does not, and the seam asserts that it stays that way |
| Data | `open-notebook-data:/app/data` (named volume: uploads, LangGraph SQLite checkpoints, extraction caches) **plus** the interim host bind `/home/dank/media/podcasts:/app/data/podcasts` (`open-notebook.container:87-88`); database in the `open-notebook-db` named volume |
| Networks | app: `dankclaw_default` first, then the private `systemd-open-notebook` (10.89.4.0/24, `open-notebook.network:33`); database: the private bridge only. Order is load-bearing — the generated `--network` flags keep file order, so the default route rides the network where `host.containers.internal` is proven |
@@ -33,6 +34,17 @@ open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook))
| Env | `scripts/open-notebook.env` (repo, knobs) then `/home/dank/Containers/open-notebook/env` (host, secrets, mode 600) — two `EnvironmentFile=` lines per unit, repo file **first** because the last file wins |
| Deployer | `deploy_open_notebook` in `scripts/deploy-remote.sh`: seed secrets → ensure both digest pins are in local storage → install both units + the network → `mkdir -p /home/dank/media/podcasts` → restart database, wait for its container health → restart app → `wait_for_health open-notebook http://127.0.0.1:5056/health 180` |
## The guard chain, and the single origin
`notebook.franksdank.cloud` follows the fleet's exposure policy in this order, and the order is the law ([[Network Architecture]]):
1. **Provider first.** Authentik proxy provider `notebook-proxy` existed before any route pointed at the app: `external_host https://notebook.franksdank.cloud/` (exact vhost, trailing slash), `internal_host http://caddy:8017`, the three flows copied from `embedded-outpost`, Application `Open Notebook` (`notebook-guard`), and the provider attached to the embedded outpost's provider set. Flip the vhost first and authenticated traffic lands on the outpost's error page. Verify after the change that the outpost logged no `Failed to fetch outpost from API` (the null-flow wedge) and that an already-guarded vhost still answers.
2. **Then the route, in one deploy:** the guarded site block (`reverse_proxy anubis:8923` with `header_up X-Real-Ip {remote_host}` — the whole request, so no path ever reaches the app unchallenged) plus the post-auth `@notebook` handler on `http://:8017`, plus the LAN record in `dankclaw/pihole/addn-hosts`. The public Porkbun A record is a host action, not a repo file.
3. **One upstream, not two.** `reverse_proxy 192.168.0.143:8502`: the image's own Next.js layer rewrites `/api/*` to the FastAPI beside it (`next.config.ts`, `INTERNAL_API_URL` default `http://localhost:5055`), so the API needs no route and no outward face — hence `127.0.0.1:5056`. Caddy dials the published LAN port rather than a container name because this Caddy lives in root's Podman and cannot see a rootless container at all ([[Container Network Namespaces]]). SSE (`/api/search/ask`, chat) streams through the same bare proxy that carries OpenWebUI's chat at `ai.franksdank.cloud`; no `flush_interval` or timeout directive is needed, and adding a route timeout is precisely what would cut it.
4. **`API_URL` must name the vhost.** `GET /config` (the Next route) answers in priority order: `API_URL` env → autodetect from `Host` + `X-Forwarded-Proto` → `http://localhost:5055`. Autodetect appends **`:5055`**, a port that belongs to seerr on this host, so leaving `API_URL` unset hands the browser an address nothing answers and every call fails under a UI that loaded fine. `CORS_ORIGINS` is set to the same single origin only to keep the app from defaulting to `*`. `scripts/open-notebook.env` carries both.
The app's own login stays on behind the chain. It is not redundant: publishing the API on loopback does **not** hide it from this host's other containers, which can dial `open-notebook:5055` by name on `dankclaw_default`. The chain decides *who from outside* gets in; `OPEN_NOTEBOOK_PASSWORD` decides what anything that already reached the port may do.
## What the image actually runs
One container, three processes under supervisord (`supervisord.conf` upstream): `uvicorn api.main:app --port 5055` (`:12`), the job worker (`:25`), and the Next.js frontend (`:39`). Two consequences the config depends on:
@@ -44,25 +56,43 @@ Also from the image itself: `WORKDIR /app` and `EXPOSE 8502 5055` (`Dockerfile:8
`DATA_FOLDER` is hard-coded `"./data"` (`open_notebook/config.py:5`) and `PODCASTS_FOLDER` is `"./data/podcasts"` (`:19`); the database stores each episode's audio path **relative** to that folder (`episodes/<uuid>/audio/<uuid>.mp3`), and the download route is `GET /api/podcasts/episodes/{id}/audio`. That is why covering one subdirectory with a bind mount is sufficient — nothing in the database has to change if the mount moves.
## Provisioning the providers (after first boot; UI or REST, never a unit)
## What is provisioned (REST, 2026-10-19)
Credentials are stored in the database, encrypted, and are **not expressible in env or a Quadlet file**. The sequence is one-time, in **Manage → Models**, and both entries use provider type **OpenAI Compatible** (upstream: any server that implements `/v1/audio/speech` works this way — `docs/5-CONFIGURATION/local-tts.md:5`).
Credentials live in the database, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`, and are **not expressible in env or a Quadlet file** — the UI (**Manage → Models**) or the REST API are the only doors. Both use provider type `openai_compatible`, because any server that answers `/v1/audio/speech` or `/v1/chat/completions` works through it (`docs/5-CONFIGURATION/local-tts.md:5`).
1. **TTS credential** — base URL **`http://frank-tts:7772/v1`**. The `/v1` is part of the URL: the client posts to `<base_url>/audio/speech`, so a base URL without `/v1` 404s on every dialogue line (`local-tts.md:72-80`, the base-URL table, always carries `/v1`). `frank-tts` resolves by container name because the app is attached to `dankclaw_default`, the network frank-tts's unit joins (`dankclaw/voiceservice/quadlet/frank-tts.container:51-52`). If frank-tts is ever moved off that network, the published-port form is `http://192.168.0.143:7772/v1` — a published port is dialable at the host's LAN address from any netns. The API-key field stays empty; frank-tts does not authenticate.
2. **LLM credential** — base URL **`http://host.containers.internal:7771/v1`**, model row `frank-zealot`. frank-zealot is a **host process** on `0.0.0.0:7771` (`ss -ltn` on Dankclaw shows the listener with no container owning it, 2026-10-18), and on this netavark host a bridge container reaches the host's LAN IP only at a *published* port — which is why Prometheus scrapes the same endpoint as `host.containers.internal:7771` (`shared/prometheus/prometheus.yml:89-111`, "not their own LAN addresses"). See [[Container Network Namespaces]]. `http://192.168.0.143:7771/v1` is the variant to try only if the gateway name ever stops resolving; it is expected to be refused from a bridge netns.
3. **Model rows are created by name.** Discovery is optional (see the next section): type the model id and confirm the add. One base URL per credential, so the TTS and LLM credentials are necessarily two separate configurations.
| Record | Value |
|--------|-------|
| Credential `frank-tts` | modality `text_to_speech`, base URL `http://frank-tts:7772/v1`, no API key (the shim does not authenticate) |
| Credential `frank-archon` | modality `language`, base URL `http://192.168.0.235:7770/v1` |
| Model row `voxcpm2` | type `text_to_speech` on the frank-tts credential. The name is decorative: frank-tts ignores the request's `model` field and synthesizes whatever `voice` names ([[Frank TTS]]) |
| Model row `frank-archon` | type `language`; `PUT /api/models/defaults` points chat, transformation and large-context at it. **The embedding default stays null — nothing in the fleet serves `/v1/embeddings`** |
| Speaker profile `dank-duo` | two speakers, Frank → `voice_id: narrator1`, Comrade → `narrator2`, `voice_model` = the TTS row |
| Episode profile `dank-daily` | `num_segments: 4`, `speaker_config` = `dank-duo`, outline and transcript LLM = the archon row, briefing that ends "Never invent numbers." |
**Verify reachability before saving**, from inside the container, because both addresses are netns-dependent:
Order that works over REST: credentials → model rows (`POST /api/models` with `credential: <credential id>`) → `PUT /api/models/defaults` → speaker profile (`voice_model: <model id>`) → episode profile (`speaker_config` / `outline_llm` / `transcript_llm`). `POST /api/models/{id}/test` proves each row: the language row answered with a real completion from frank-archon; the TTS row reached frank-tts and came back `unknown voice 'alloy'; available voices: narrator1, narrator2, narrator3` — the app's own test path hard-codes an OpenAI voice, so that message *is* the proof the leg works.
**`POST /api/podcasts/generate` takes profile NAMES, not record ids.** `episode_profile: "dank-daily"` works; the `episode_profile:…` id returns a bare `500 Failed to submit podcast generation job`, and the actual reason (`Episode profile '<id>' not found`) appears only in `podman logs open-notebook`.
**The scriptwriter is frank-archon, not the planned frank-zealot, because two rules collide on the address.** Measured from inside the app's netns 2026-10-19:
- `192.168.0.143:7771` (frank-zealot, a host-netns listener) and `192.168.0.143:7770` (frank-dragoon, a **root**-published port) both refuse the connection; `host.containers.internal:7771` answers. Full census: [[Container Network Namespaces]].
- The app refuses to **store** that one working address: its URL guard rejects any base URL that resolves inside link-local `169.254.0.0/16` (`open_notebook/utils/url_validation.py`, "Link-local addresses are not allowed (metadata endpoints)"), unconditionally — no env var touches it, and `AddHost=` cannot dodge it because the check runs on the resolved address.
`http://192.168.0.235:7770/v1` — another machine, plain RFC1918 — is the only fleet LLM address that passes both tests. Cost: frank-archon runs `--max-num-seqs 2` and the operator session itself lives there, so a render is a slow-down for whoever else is talking to that endpoint, not a stall; `ESPERANTO_LLM_TIMEOUT=180` covers an outline or transcript call comfortably.
**frank-dragoon can write transcripts, but only greedily — and open-notebook cannot ask for greedy.** Measured 2026-10-19 against the live endpoint: `response_format` as `json_schema` or `json_object` returns schema-valid JSON at `temperature: 0` and a **400** at any sampled temperature — halogen decodes greedy under a schema, and because its sampler default is server-side, omitting `temperature` is sampled, not greedy. That 400 is the trap: esperanto's `is_json_schema_unsupported_error` matches the words *not implemented* in it, so `_SchemaFallbackModel` in `podcast_creator/nodes.py` reads it as "this endpoint has no schema support", downgrades to `json_object`, gets the same 400, and **fails the run** — esperanto's own rescue (drop `response_format` entirely) fires only on the different message `'response_format.type' must be 'json_schema'`. The full law is on [[frank-dragoon]]. And the fix is not reachable from here: a model record is `{credential, name, provider, type}` and a model response adds only timestamps (`GET /openapi.json`, `ModelCreate`/`ModelResponse`) — **there is no per-row config field**, no way to attach `temperature: 0` to one row from the UI or the API. Making `frank-dragoon` the writer would therefore need something in the path that rewrites request bodies, which is a heavier tenant than the contention it removes. **frank-archon stays the scriptwriter.** Reachability would be a separate problem anyway: `frank-dragoon` is not on `dankclaw_default` (the serve script passes no `--network`), so nothing on this network has a name for it, and the one address that does answer — `host.containers.internal:7770` — is the link-local the guard refuses to store ([[Container Network Namespaces]]).
**No embedding model is deployed, and saving a note is where that shows up.** Every note save submits an `embed_note` command that fails in the worker with `No embedding model configured. Please configure one in the Models section.` The note itself is stored and readable; semantic search, source ingestion and notebook-context questions stay dead until something serves `/v1/embeddings`. Podcast generation from `content` does not need embeddings, which is why renders work today. The cheapest door is **halogen's NPU lane, not a new endpoint**: `qwen3-embedding-0.6b` is already in the catalog of the image `frank-dragoon` runs and answers `/v1/embeddings` on the same port (1024 unit-length floats; the instruct prefix goes in the text, and `check_embedding_ctx_length=False` for LangChain). What enabling it costs — the accel device, the host XRT mount, and the fabric-clock hold that GPU+NPU co-run requires — is on [[frank-dragoon]]; after that, the embedding row still has to clear the reachability rule above.
**Secrets never go in a command line.** The API's bearer token *is* `OPEN_NOTEBOOK_PASSWORD`, and a `-H "Authorization: Bearer …"` argument is world-readable in `/proc/<pid>/cmdline`; put it in a mode-600 curl config and pass `-K`:
```bash
podman exec open-notebook curl -sS http://frank-tts:7772/health
podman exec open-notebook curl -sS http://host.containers.internal:7771/v1/models
PW=$(grep -m1 '^OPEN_NOTEBOOK_PASSWORD=' /home/dank/Containers/open-notebook/env | cut -d= -f2- | tr -d '"')
cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: Bearer %s"\n' "$PW" > "$cfg"
curl -s -K "$cfg" http://127.0.0.1:5056/api/models | head -c 400
rm -f "$cfg"
```
**The blocker moved; it did not vanish.** frank-tts and frank-tts-engine are **deployed** (2026-10-18) and answer on 7772, so the voice leg works end to end — `podman exec open-notebook curl -sS http://frank-tts:7772/health` is the check to run the day the app exists. What still blocks a render is open-notebook itself: not installed on this host yet. When it is, the two provider rows above are the only wiring, and the seeded speaker profiles carrying OpenAI voice names are the first trap.
**Why frank-zealot and not frank-dragoon:** podcast scriptwriting asks the model for a JSON-schema response (with a plain-JSON fallback). The decision rests on each endpoint's documented request surface ([[frank-zealot]], [[frank-dragoon]]): zealot's is a known-good target for that call shape, dragoon's structured-output behaviour is **unverified**, so it is the unproven alternative, not the fallback. `ESPERANTO_LLM_TIMEOUT=180` (the app's own default) covers an outline or transcript call on zealot comfortably; upstream's note is to keep it under 600 so a slow call fails with a clear error instead of a UI timeout.
## Speakers and voices: 1–4, and the IDs are free text
A **speaker profile** carries one to four speakers (`docs/3-USER-GUIDE/creating-podcasts.md:27`), each with a personality and a **Voice ID that is typed exactly and passed through to the TTS request untouched** (`:29`) — there is no whitelist, no enum, no validation against a voice list. So the fleet's voice-pack names work verbatim:
@@ -92,9 +122,11 @@ Defaults are `TTS_BATCH_SIZE=5` and `OPEN_NOTEBOOK_WORKER_MAX_TASKS=5` (`docs/5-
The clock next to it: `ESPERANTO_TTS_TIMEOUT` (`:91`) is patience **per dialogue line**, not per episode — a three-minute episode is dozens of separate POSTs, and the tail of a long turn on a CPU engine goes past upstream's 300 s default. It is set to **660, one round-trip outside `SHIM_TIMEOUT=600`** in `scripts/frank-tts.env` (frank-tts's own per-request ceiling): nested budgets go inside-out — the same rule that file states for `LLAMA_TIMEOUT` — so a frank-tts give-up arrives labelled as frank-tts's error instead of the worker's own timeout, and re-measuring frank-tts cannot silently strand this number below it. An abandoned request does not cancel the engine process; it only strands the episode in "generating".
What the guard costs, measured on that first render (the deployed shape): 23 dialogue clips, one POST each, **10–11 s per clip** with both knobs at 1 → 301 s of wall clock for 747 s of speech, i.e. **2.5× real time**, box still responsive. That ratio is the budget to hold any future TTS engine to: halve the per-clip time, halve the episode.
## Secrets, and the difference between blank and unset
`OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach the published port. That is a real exposure on a LAN-published service with no vhost and no guard chain in front of it, which is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`).
`OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach it. Moving the API to loopback did not remove that exposure, it only removed the LAN from it: any container on `dankclaw_default` dials `open-notebook:5055` by name, so an unset password is an open episode library to every container on this host, guarded or not. That is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`).
The tracked file declares the secrets **empty** and the host file supplies them; podman applies `--env-file` arguments in order and the last one wins (verified against the generated `ExecStart` line, 2026-10-18). Reordering the two `EnvironmentFile=` lines in a unit would blank every credential — that is the trap the seam's ordering assertion exists for.
@@ -102,7 +134,7 @@ The database has the same trap in a different spelling: the app reads `SURREAL_P
## Episode destination: interim, and reversible (issue #7)
**Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; finished audio additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory, readable without `podman unshare`, which the database volume would require (measured with the pinned SurrealDB image).
**Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; everything the render produces additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). The first render confirmed the layout: `episodes/<uuid>/clips/0001.mp3 … 00NN.mp3` (one file per dialogue line, written as the worker goes) and `episodes/<uuid>/audio/<uuid>.mp3` (the ffmpeg concatenation). A half-finished render is therefore visible and greppable from the host without `podman unshare`. That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory.
Reversibility is one line: delete the `Volume=/home/dank/media/podcasts:/app/data/podcasts` line and the episodes go back to living solely inside the named volume. Nothing else in the config assumes the bind. See [[Storage Policy]] for the repo's storage conventions this interim state deliberately bends.
@@ -118,10 +150,11 @@ podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f7
## Rollback
- **Nothing deployed (today's state):** revert the commits. There is no host state to undo — no unit, no container, no volume, no `/home/dank/media/podcasts` directory beyond what the deployer would create.
- **Deployed:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources.
- **Take it off the air, in the reverse of the deploy order:** delete the guarded site block and the `@notebook` handler from `dankclaw/Caddyfile` and deploy `caddy`; then in Authentik delete the `notebook-guard` Application and the `notebook-proxy` provider and drop it from the embedded outpost's provider set. A **route with no provider 502s** while a provider with no route is inert — that asymmetry is why the chain unwinds Caddy-first.
- **Stop the app:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources.
- **Kept on purpose:** the `open-notebook-data` and `open-notebook-db` named volumes and `/home/dank/media/podcasts`. Episode audio and the notebook database are user data; removing them is a separate, deliberate act, and episode files are host files precisely so they can outlive the container.
- **Homepage:** the card is `wip: "down"` + `showLink: false`, so retiring the service needs no card surgery beyond setting it back to that state.
- **Provider credentials are database rows**, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`; no repo file mirrors them. Regenerating that key while the database volume survives makes every stored credential undecryptable and forces re-entry — so a rollback that keeps the volume keeps the key file with it.
- **Homepage:** the card test in `tests/open_notebook.bats` pins the live state (`showLink: true`, link = the vhost), so retiring the service edits that test in the same commit as the card flip back to `wip: "down"`.
## Citations
@@ -130,3 +163,7 @@ podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f7
- Registry (2026-10-18): `lfnovo/open_notebook` tags (`v1-latest`, `latest`, the `-single` set) and manifest-list digests; `surrealdb/surrealdb` `v2` → 2.7.0
- Local measurements (2026-10-18, workstation, pinned digests): `surreal start --help` (credential and bind options carry `[env: SURREAL_*]`); running the pinned SurrealDB digest with env-only credentials (credential init in the log, correct login authenticates, wrong password refused, `/surreal is-ready` → `OK` exit 0, named volume owned by the mapped uid); the v1-compatible Quadlet generator dry-run of all three unit files (two `--env-file` in order, two `--network` in order, named volume + host bind, both health probes, `--pull never`)
- Host evidence (Dankclaw, 2026-10-18, read-only): `ss -ltn` (`127.0.0.1:5055` held by seerr, `0.0.0.0:7771` a host listener, nothing on 7772/8502/5056/8000), `podman ps -a` (no open-notebook container, `dankclaw_default` present), subnet registry from `dankclaw/open-webui/quadlet/open-webui.network`
- Deploy + provisioning (Dankclaw, 2026-10-19): first-boot log — migrations 1→25, `Credentials were provided, and no root users were found. The root user 'open_notebook' will be created`, `api`/`worker`/`frontend` all RUNNING under supervisord; `GET :8502/config` → the `API_URL` value; `GET /api/providers` (the `openai_compatible` modality list); `GET /openapi.json` schemas for `CreateCredentialRequest` / `ModelCreate` / `SpeakerProfileCreate` / `PodcastGenerateRequest`; the POSTs listed above; the note-save worker error `No embedding model configured.`
- First render (2026-10-19): job `command:9rdojq5ne7dj9caicq20`, `execution_time: 301.21 s`, `episode:7ft03thj98sn0ek9xf29`; `episodes/d5fed29b-33e9-40ac-b46a-e0537c56c256/clips/0001.mp3 … 0023.mp3`; `…/audio/d5fed29b-….mp3` = 11,957,420 bytes, mp3 24 kHz mono, `ffprobe` duration 747.31 s; `GET /api/podcasts/episodes/{id}/audio` → 200 `audio/mpeg` with the bearer, 401 without.
- Upstream code read inside the pinned image (2026-10-19): `frontend/next.config.ts:26-43` (the `/api/*` rewrite and the one-port claim), `frontend/src/app/config/route.ts:122-173` (`/config` priority order; autodetect appends `:5055`), `frontend/src/app/api/_sse-proxy.ts` (SSE proxied with `no-transform`), `open_notebook/utils/url_validation.py` (link-local refusal, `is_blocked_ip`, no environment override anywhere in the file).
- Guard chain (2026-10-19): provider `notebook-proxy` created through `podman exec -i authentik-server ak shell` with the flows copied from `embedded-outpost` (pk 1), Application `Open Notebook`/`notebook-guard`, embedded-outpost provider set 9 → 10; afterwards the outpost logged zero `Failed to fetch outpost from API` and `seerr`/`grafana` still answered 200. Before the route existed, `notebook.franksdank.cloud` resolved to nothing (`000`).
@@ -43,10 +43,12 @@ Dankclaw is an AMD APU + RTX 3090 inference host. It ran CIRU ROCmFPX llama.cpp
| Device | /dev/accel/accel0 |
| Driver | amdxdna 0.7.0, in-tree — bound since 2026-10-18 (`lspci -k -s c6:00.1` → `Kernel driver in use: amdxdna`; journal 2026-10-18 01:25:14 `Initialized amdxdna_accel_driver 0.7.0 for 0000:c6:00.1 on minor 0`) |
| Firmware | 1.1.2.65 (`/usr/lib/firmware/amdnpu/17f0_11/npu_7.sbin` → `npu.sbin.1.1.2.65.zst`, loaded at boot 2026-10-18 01:25:13) |
| Runtime | None installed. FastFlowLM 0.9.45 + XRT (installed 2026-08-16) was **retired 2026-09-11** with the `amd_iommu=off` decision — see [[dankclaw-iommu-translation-off]]; the package remains at `/home/dank/GitPackages/fastflowlm_0.9.45_ubuntu26.04_amd64.deb` |
| Status | Not in service — no NPU endpoint is deployed. The device itself is usable since the 2026-10-18 `iommu=pt` boot (`/dev/accel/accel0` present, `crw-rw---- root:render 261,0`); Gitea issue #3 tracks enablement. See |
| Runtime | Distro XRT only: `libxrt2` + `libxrt-npu2` 1:2.21.75 supply `libxrt_coreutil.so.2`, `libxrt_core.so.2` and the NPU plugin `libxrt_driver_xdna.so.2` under `/usr/lib/x86_64-linux-gnu`; **no `/opt/xilinx/xrt`** (census 2026-10-19), no Ryzen AI Python stack. FastFlowLM 0.9.45 + AMD's XRT (installed 2026-08-16) was **retired 2026-09-11** with the `amd_iommu=off` decision — see [[dankclaw-iommu-translation-off]]; the package remains at `/home/dank/GitPackages/fastflowlm_0.9.45_ubuntu26.04_amd64.deb` |
| Status | Not in service — no NPU consumer is deployed. The device itself is usable since the 2026-10-18 `iommu=pt` boot (`/dev/accel/accel0` present, `crw-rw---- root:render 261,0`); Gitea issue #3 tracks enablement. The only runtime here that ships precompiled programs this device can execute is halogen's — see [[frank-dragoon]] §The NPU lane |
The NPU endpoint is retired (2026-09-11; ). The `frank-zealot` name now serves **Ternary-Bonsai-2-27B-PQ2_0** on the RTX 3090 via the llama.cpp PrismML fork (see ); the Gemma4-12B QAT rung it once pointed at is retired history (, 2026-09-17 → 10-01).
This section is device inventory, not a payload record. The name `frank-zealot` served the FLM NPU endpoint until
2026-09-11 and has served Bonsai-2 on the RTX 3090 through the llama.cpp PrismML fork since; the checkpoint, the
shape and every era are on [[frank-zealot]], and any figure restated here is the next thing to go stale.
### ROCm
@@ -1,9 +1,9 @@
---
type: reference
title: LLM Performance Diagnosis
description: "Probing why a served model is slow: the flat-depth probe, offload duty-cycle arithmetic, and profiler attribution."
description: "Probing why a served model is slow: the flat-depth probe, offload duty-cycle arithmetic, and step-shape attribution."
tags: [performance, profiling, moe, pcie, prefix-cache, routing, telemetry]
timestamp: 2026-10-05T00:00:00-07:00
timestamp: 2026-10-19T00:00:00-07:00
---
# LLM Performance Diagnosis
@@ -213,9 +213,75 @@ function directly with synthetic bodies (stdlib-only policy module)
conversation identity is the FIRST N KiB of prompt — a shorter tested prefix
hashes differently per turn and pinning silently never happens.
## 9. Decode cadence under a prefill (the step-shape census)
"Decode turns to sludge the moment a long prompt arrives, then recovers" is not a
link or a gather story — on a chunked-prefill engine the unit of interference is
**the step**. A step that carries a prefill chunk takes the chunk's time, and every
decode row sharing that step waits that long for its next token. Chunk size is
`min(remaining, --checkpoint-interval, --max-num-batched-tokens)`, so on a hybrid
model the interval caps it before the budget does.
Census it without touching the model — the engine's own counters (`/stats`,
`/metrics`, cumulative since boot):
```
decode-only step = step_ns_decode / steps_decode
shared step = (step_ns - step_ns_decode) / (steps_prefill + steps_mixed)
```
Then bucket a day of `decode_tokens_per_second` by `prefill_tokens_per_second`
(Prometheus `query_range`, step 120 s) and **check the lag**: correlate decode with
prefill at lag 0 and again at 2-14 minutes. Lag-0-only ⇒ step-time contention, the
chunk is holding the device. A lagged tail ⇒ residency churn — the prefill's working
set evicted something decode needs (expert slab, KV) and decode pays after the
prefill is over. `preemptions` separates the third shape: a preempted-and-recomputed
sequence stops producing entirely and then re-prefills from token 0, which is a
stall, not sludge.
Knobs, in the order worth trying them (radiance GUIDE §4.5):
- `--prefill-interleave N` — after a chunk step taken beside decoders, the next N
steps carry decode rows only and admit nothing. Decoders keep their cadence; the
prompt pays that in TTFT. Counts as `engine_steps_held_total`.
- `--busy-prefill-chunk MODE` — the chunk's share of a step that has other work:
`auto` (default) reserves a row plus the draft window per decode row behind it and
an eighth of the budget per waiting prefill/request, `off` lets the chunk take the
whole step (admission never runs while it does), `N` pins the share. Never under
the prefill stager's smallest pass — the startup line names it, and below it every
routed layer reads its pooled experts synchronously, which costs the prefill far
more than sharing saves. Counts as `engine_prefill_chunks_capped_total`.
- `--max-num-batched-tokens` — the blunt rung, and the only one older pins have. The vendor's own
curve prices **halving the shared chunk at roughly a fifth of prefill throughput** (a 48-layer MoE
at 200k on two cards — knob pricing, not this endpoint's number, so treat it as the order of
magnitude a chunk decision costs). Raising it past the checkpoint interval buys nothing and loses
expert residency to the arena.
- `--prefill-span N` — the inverse trade: a *lone* prefill carries N intervals a step while every
shared step is planned at a smaller shared budget, which on an expert-tiered box lends that arena
back to the expert slab. The vendor's own measurement says the win is **long-prompt-only**: nothing
at short prompts, a large TTFT gain at the far end of the window.
Two traps. **The arena/slab coupling makes a shared step doubly expensive on an
expert-tiered shape**: the full-plan arena takes VRAM the resident expert slab would have had, so the
decode rows inside that step also read experts over the link — a smaller shared budget helps twice.
And **re-check
`prefix_hit_rate` / `prefix_evictions` after any chunk change**: the arena and the KV
pool are the same memory, and the failure looks like turn two taking as long as turn
one.
Availability is a version question, not a config question: `--busy-prefill-chunk` and
`--prefill-interleave` are absent from older pins — prove it on the running container
(`podman exec <c> <binary> --help | grep -iE "prefill|chunk"`), never from the vendored
tree, and check the source clone's depth before dating a flag from `git log -S`
(a `--depth 1` clone reports its own tip for every search).
Run on: [[frank-archon]] — the census was taken on its 3-slot radiance 1.0.13 era (budget 2048 =
interval 2048); the live 1.8.1 shape carries the two chunk knobs. The measured census and the verdict
for this fleet's shape are that page's.
## Related
- the window discipline and usage-counter law
- [[PCIe Link and Atomics]] — link probing and the streaming-vs-compute ceiling
- [[RDNA4 MoE Expert Offload VRAM Budget]] — the plan behind duty-cycle terms
- the compile fix for the pointwise-glue bound
- [[frank-archon]] — the endpoint where §9's step-shape census was run
+114 -45
View File
@@ -1,6 +1,6 @@
---
type: reference
title: "frank-archon on Dankserver — radiance (StillDeadcode) 1.0.13, publisher Qwen3.8-Flash-Next iq4r .rad, 2× R9700 TP=2 (ACTIVE)"
title: "frank-archon on Dankserver — radiance (StillDeadcode) 1.8.1, publisher Qwen3.8-Flash-Next fp8-iq4r-moe .rad, 2× R9700 TP=2 (ACTIVE)"
description: "The radiance endpoint: live shape and harness contract, every era with its rollback path, and the laws it taught."
tags:
- llm
@@ -12,7 +12,7 @@ tags:
- tp2
- expert-offload
- endpoint
timestamp: 2026-10-18
timestamp: 2026-10-19
---
# frank-archon — radiance (StillDeadcode), 2× R9700 TP=2, Dankserver :7770
@@ -20,8 +20,9 @@ timestamp: 2026-10-18
The radiance engine (C++/HIP, `libr4d` kernels built for gfx1201, no Python in the serving
path) driving `podman run` straight from the digest-pinned image under a **root** systemd
unit; container name = unit name. Host Dankserver (192.168.0.235), port 7770, both Radeon AI
PRO R9700s. Capacity contract **3 conversations × 200,000 tokens at fp8 KV**; a payload swap
is a `MODEL_FILE` line plus a re-download — see Eras.
PRO R9700s. Capacity contract **2 conversations × 200,000 tokens at fp8 KV** — at 1.8.1 that pair
is the **demand signal** the budget resolver sizes the cache against, not a KV allocation (Laws).
A payload swap is a `MODEL_FILE` line plus a re-download — see Eras.
## Current shape
@@ -44,14 +45,20 @@ declared size + sha256) as the transient unit `iq4r-fetch` (log
`/home/dank/models/StillDeadcode/…` keeps card, LICENSE and recipe as provenance, and only
`.rad` files are mounted.
**Runtime** — `stilldeadcode/radiance@sha256:4c3f484564bb2872784e46b0a59cbd71becc9c1e746e301a3596ffae253c322e`
= pin **1.0.13** (Codeberg `v1.0.13`, commit `53282fa`): a 287 MiB image in dank's
**rootless** storage, host needs nothing but the amdgpu driver. Pin the INDEX manifest digest,
never the image ID (`69d05724…` is the config digest and sends `podman run` to the network).
The serve script's gate is that digest ref itself — `podman image exists name@sha256:…` in one
command (the 2026-09-30 no-pipe/grep-race lesson) — so a retagged look-alike or a newer
`latest` cannot satisfy it. 1.2.0 is a rejected serve pin (Laws); the
`--vram-weights-mib`/`--vram-kv-mib` pair is accepted by both pins, which is the rollback symmetry.
**Runtime** — `stilldeadcode/radiance@sha256:d590e300234bf6d2f942a0b8d456d183153fd808f28820e6c2fa69c9389178ed`
= pin **1.8.1** (adopted 2026-10-18). The version oracle is the boot's own `STARTUP REPORT --
radiance 1.8.1` line, because `podman inspect` reports only the digest. The image lives in dank's
**rootless** storage and the host needs nothing but the amdgpu driver. Pin the INDEX manifest
digest, never the image ID (`69d05724…` is the config digest and sends `podman run` to the
network). The serve script's gate is that digest ref itself — `podman image exists name@sha256:…`
in one command (the 2026-09-30 no-pipe/grep-race lesson) — so a retagged look-alike or a newer
`latest` cannot satisfy it. 1.2.0 is a rejected serve pin (Laws). **Two CLI breaks ride this pin,
and both are boot refusals, never silently-ignored flags:** the `--vram-weights-mib`/`--vram-kv-mib`
pair died at 1.3.0 (the resolver derives the split) and `--prefix-cache-dir` /
`--prefix-cache-disk-mib` died with the disk tier. A rollback to 1.0.13 (`sha256:4c3f4845…`) is
therefore a trio rewrite, not a digest swap — the Eras row carries it, and
`tests/frank_archon_unit.bats` asserts the retired knobs are ABSENT from env and argv so a stale
line cannot reach a boot.
**Trio** — `scripts/frank-archon.env` (every number, with its arithmetic in the header)
`scripts/serve-frank-archon.sh`, `scripts/frank-archon.service`. Gates run in order: image
@@ -64,8 +71,10 @@ the name registered, and every one of those identities holds this GPU. Container
(publisher-vetted, `docs/DOCKER.md`): `--network host`, `--init` (so the engine releases the
cards on SIGTERM), `--security-opt seccomp=unconfined` (the default profile refuses io_uring —
the PLE table's reads — and the NUMA calls the ROCm runtime makes for host memory), `--ulimit
memlock=-1:-1`, `/models` ro + `/data` rw + the tune cache at `/opt/radiance/share/radiance/tune`
template as a single-file mount, then `exec podman wait`: the script's lifetime IS the
memlock=-1:-1`, `/models` ro + the rad-tune cache at
`/opt/radiance/share/radiance/tune` + the template as a single-file mount (there is **no `/data`**
any more: it left with the prefix disk tier, and `FRANK_ARCHON_STATE_DIR` is now only the host-side
directory that holds `tune/`), then `exec podman wait`: the script's lifetime IS the
container's, so a crash is a nonzero `ExecStart` and the restart policy is real.
Unit: `After=network-online.target`, `WantedBy=multi-user.target`, `User=dank`
`LimitMEMLOCK=infinity`, `StartLimitIntervalSec=0`, `OOMScoreAdjust=-500` (the last process the
@@ -79,27 +88,33 @@ on stop. Boot budget 1800 s (the publisher's compose allows a 15-minute `start_p
| Knob (env name, `FRANK_ARCHON_` dropped) | Live value |
|---|---|
| TP / wire | `TP=2`, `TP_WIRE=wht6` — libr4d one-shot all-reduce, not RCCL; `min-kb` stays the engine default |
| Slots / window | `MAX_SEQS=3`, `MAX_MODEL_LEN=200000` (native 262,144; longer is refused, never truncated) |
| KV dtype | `KV_DTYPE=fp8` — the only dtype that funds 3 slots (Laws) |
| VRAM pair | `VRAM_WEIGHTS_MIB=25600` + `VRAM_KV_MIB=5120` = 30,720 of the 30,969 ceiling; `GPU_HEADROOM_MIB=96` |
| Slots / window | `MAX_SEQS=2`, `MAX_MODEL_LEN=200000` (native 262,144; longer is refused, never truncated) — this pair is the **cache bound**, not a KV allocation |
| KV dtype | `KV_DTYPE=fp8` — the only dtype that funds the slot count (Laws) |
| VRAM split | **derived at boot** from `GPU_HEADROOM_MIB=96` + the cache bound + `HOST_POOL_MIB`; naming `VRAM_WEIGHTS_MIB`/`VRAM_KV_MIB` is a boot refusal (removed at 1.3.0) |
| Expert tier | `PLACEMENT=expert_tiered`, `HOST_POOL_MIB=44928` **per rank** |
| PLE | `NGRAM_PLACEMENT=ram` (47.7 GiB pinned; refuses boot on a short memlock) |
| Spec / batch | — |
| Prefix cache | `PREFIX_HOST_MIB=8192` + `PREFIX_DISK_MIB=65536` under `STATE_DIR=/home/dank/radiance-state` (mounted rw at `/data`) |
| Spec / batch | `SPEC_TOKENS=2` — MTP depth 2, the `.rad` carries the model's own `mtp.*` head (`mtp.fc_hidden`/`fc_embedding`/`draft_head`); `MAX_BATCHED_TOKENS=2048` (MNBT — 2560 collapses decode, Laws) |
| Prefix cache | `PREFIX_CACHE_MIB=8192`, pinned **host tier only** — the 64 GiB disk tier is gone, so an evicted prefix is re-prefilled; `STATE_DIR=/home/dank/radiance-state` now exists only to hold the rad-tune cache |
| Template | `TEMPLATE=scripts/qwen38-froggeric-v22.5.jinja` (28,234 B) via `--override-chat-template` — froggeric's fixed Qwen3.8 template (the repo root's `chat_template.jinja`, vendored); the checkpoint's own template is baked into the `.rad` by rad-convert and this overrides it, and radiance derives the reasoning and tool-call reply format from it |
| Step sharing | `BUSY_PREFILL_CHUNK=auto` (a shared chunk reserves a row plus the draft window per waiting decoder) · `PREFILL_INTERLEAVE=0` — held at zero until the decode-vs-prefill buckets ask for it |
| TTM gate | `TTM_MIN_MIB=150000`, `TTM_TARGET_MIB=204800` |
| Boot | `RAD_MIN_MIB=110000` deployer floor; the listener binds only after the full load (health ~100 s, budget 1800 s) |
Boot ledger for this shape (2026-10-15, pin 1.0.13): pool 5.00 GiB backs **607,740 tokens**
(`kv_attn` fp8 page 26.00 KiB, `kv_qsa_bkey` 3.25 KiB), `kv_gdn_state` 54 MiB × 6 slots, slab
**17,548 slots** with 4.22 GiB of KV lent to it, sampler **9 rows**, template 28,234 B, vision
live (≤ 4,194,304 px, 16,384 patches/pass).
Boot ledger for the live shape (2026-10-19 11:10, pin 1.8.1, `MAX_SEQS=2`, `HOST_POOL_MIB=44928`):
the resolver prints `elastic 26.73 GiB` → **experts 23.73 GiB (89 %) / kv cache 2.76 GiB (10 %)**,
`vram_weights 27.31 GiB`, `host_pool 44.47 GiB`. `kv_attn` (13 layers, 4-token blocks) pages
**26.00 KiB** → 91,301 blocks = **365,204 tokens = 1.8 full-length sessions = 91 % of the
2 × 200,000 worst case** (the resolver never funds 100 %, see Laws); `mover` lends 2,146 slots
(2.54 GiB) out of the cache to the expert slab; sampler **9 rows**, template 28,234 B, vision live
(≤ 4,194,304 px, 16,384 patches/pass).
The boot **ledger**, not this arithmetic, is the referee: radiance states every budget before
anything is allocated (weights vs KV, the host pool's coverage of the worst case, the PLE pin,
the P2P answer per pair) — read it after every resize, and unlike every predecessor there is no
per-boot VRAM planner to second-guess. For comparison, the card's own suggested run line on 32
GB cards is `--host-pool-mib 12288 --expert-vs-cache-ratio 0.82` with seqs 8 and spec 3; the
per-boot VRAM planner to second-guess. For comparison, the publisher's own suggested run line on 32
GB cards was `--host-pool-mib 12288 --expert-vs-cache-ratio 0.82` with seqs 8 and spec 3 — a
1.0.x-era line, and `--expert-vs-cache-ratio` was removed at 1.2.0, so today it is a capacity floor
quoted from the card, not a run line this pin accepts; the
shape above is the fleet's measured one. `FRANK_ARCHON_EXTRA_ARGS` is the boot-ladder escape
hatch (unset → no extra argv; it is expanded unquoted on purpose, so keep multi-flag values as
one string).
@@ -130,6 +145,7 @@ marked "re-fetch" is gone from `/home/dank/models/`, which today holds only `Sti
| Window | Payload | Runtime | Shape | Replaced because | Rollback |
|---|---|---|---|---|---|
| 2026-10-15 → 10-19 | same `qwen3.8-next-flash-fp8-iq4r-moe.rad` (payload unchanged) | radiance **1.0.13**, `sha256:4c3f4845…` | 3 × 200,000 fp8 KV bought with the **explicit** pair `--vram-weights-mib 25600 --vram-kv-mib 5120`; host pool 44,928 → 45,568 MiB a rank; prefix cache 8 GiB host + 64 GiB disk | decode turned to sludge beside any long prompt — step contention, 18.7 ms decode-only steps against 317 ms steps carrying a prefill chunk (Laws) — and the only two knobs that fix it, `--busy-prefill-chunk` and `--prefill-interleave`, exist only after this pin | `FRANK_ARCHON_IMAGE` back to `sha256:4c3f4845…` **plus a trio rewrite**: restore the pair and the host+disk prefix knobs, delete `BUSY_PREFILL_CHUNK`/`PREFILL_INTERLEAVE`. A digest swap alone is a boot refusal (Runtime) |
| 2026-10-08 → 10-15 | in-house `qwen4exp-swift15-w4dn8-i8-hc8m.rad`, 116,416 MiB (Swift1.5 abliterated BF16, GPTQ w4 experts + 24 bf16 protected, KLD 0.0131 / top-1 97.92 %) | radiance 1.0.13, same digest | 3 × 200,000 fp8 KV, slab 25,600 / pool 5,120 — the shape the live file still carries | the engine author's own container is the supported path and his quality ceiling; the finetune's refusal-bending left the endpoint | `.rad` purged 2026-10-17 (trash-staged then emptied, Frank's approval). Re-download `frank8465/Swift-1.5-Qwen3.8-Flash-Next-GPTQ-W4A8-Radiance-AMD-R9700` → `qwen4exp-w4nl64-i8-hc8m.rad`, 122,071,973,888 B, LFS sha256 `09dd9306…`, via the iq4r fetch script's hfget/verify pattern retargeted at that repo; point `MODEL_FILE` at it, redeploy. Re-**convert** is dead: the BF16 source was `rm`'d 2026-10-15 (`kld-ref`, grams, corpora and `/home/dank/logs/swift15_*.log` survive) |
| 2026-10-05 → 10-08 | `qwen4exp-fp8-native.rad`, 177,109 MiB (173.0 GiB) — native modelopt block-FP8, no requantisation | radiance 1.0.7 line, same digest | — | native FP8 experts (56.4 GiB/rank) ate a 40 GiB pool and left KV at 3,543 MiB = 386,904 tokens, **1.93 sessions** — context economics, not quality; and the host pool could not be trimmed to pay for it (36,864 refused the boot) | rebuild-only: `scripts/download-frank-archon.sh` (official `Qwen/Qwen3.8-Flash-Next-FP8`, 131 shards, 172.8 GB, ungated) + `scripts/build-frank-archon-rad.sh` (no-recipe convert under the descriptor law); raw **and** `.rad` were `rm`'d 2026-10-15 (Frank's call, to free the volume for the iq4r download) |
| 2026-10-05 (same day) | `orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8` safetensors, 131 shards | FreeToken PR #587 head `77a7201` (`rocm714_torch214`), image `localhost/frank-primary-freetoken:pr587-77a7201` | — | TP=2 cannot boot on ROCm: the per-rank `pynccl.cu` JIT cannot resolve `cuda_runtime.h` for gfx1201, so the second card idled | git tag `frank-archon-freetoken-pr587-20261005` (trio + `scripts/docker/frank-primary-freetoken.Dockerfile`, which exists only there); image still in rootless storage; payload needs a re-fetch, and that repo 403s until the gate is accepted on the account |
@@ -161,7 +177,8 @@ vllm-radiance era (this table opens on 09-26; the era page claims 10-10).
2×**, precisely because the bkey plane is dtype-fixed. Secondary saving: a prefix-cache entry
tracks the attention plane (110.50 KiB at bf16 → 58.50 at fp8, per the `kv tier transfer`
lines), so the same 8 GiB host tier parks ~5.8 sessions of 200k at fp8 where it parked ~2.9 at bf16.
- **Pool sizing law.** `pool_mib ≥ tokens × 7.3125 KiB + 110 × seqs + 448` — the dtype-
- **Pool sizing law (the explicit-pair era — at 1.8.1 the cache is derived, so the resolver law below
owns it).** `pool_mib ≥ tokens × 7.3125 KiB + 110 × seqs + 448` — the dtype-
independent terms are also in the pool: `kv_gdn_state` 54 MiB × 2 copies per sequence (+conv/
tail/ple ≈ 110 MiB a sequence) and the prefix checkpoint region 8 × 55.99 MiB. Deployed: 3 ×
200,000 at fp8 = 4,285 + 330 + 448 = **5,063 MiB**, pool 5,120, which predicts (5120 − 330 −
@@ -174,13 +191,15 @@ vllm-radiance era (this table opens on 09-26; the era page claims 10-10).
pool of 3,543 gave 386,904 (1.93 sessions), and the four-slot fp8 boot of 2026-10-13 19:21
backed 843,276. The ledger's `backs N sequences all at 200000 tokens at once` line is the
referee — a pair this tight is read off the boot, never assumed, and the concurrent probe is
its regression test. Fourth full-height conversation needs 6,601 MiB of pool: refusing it is
Frank's explicit call, and serving it is a `--vram-kv-mib` change, not a code change.
its regression test. A fourth full-height conversation needs 6,601 MiB of pool: refusing it is
Frank's explicit call, and under that pin serving it was a `--vram-kv-mib` change, not a code change.
At 1.8.1 the same decision is `MAX_SEQS` — the resolver buys the cache the demand signal implies.
- **The hard limit is per request.** The engine treats `kv group tokens >= max-model-len` as a
HARD limit — "one request longer than N tokens is accepted by the API and then refused as too
large to ever fit — this is a hard limit, not a backing ratio" — so the pool is sized for
`seqs × max-model-len`, not for one sequence. Radiance never truncates, at any size.
- **One VRAM budget per card:** `weights + kv ≤ 30,969 MiB`, read off the engine's own budget
- **One VRAM budget per card** (the arithmetic below is the pair era's; the ceiling survives, the knobs
do not): `weights + kv ≤ 30,969 MiB`, read off the engine's own budget
table (boot 2026-10-13 at pair 24000/5120): card 32,624 MiB, budgets claimed 29,120, and
"*3.42 GiB is neither budgeted nor accounted — the driver's context, the activation arena and
`--gpu-headroom-mib` come out of it; anything past those is throughput on the floor*", with
@@ -188,12 +207,34 @@ vllm-radiance era (this table opens on 09-26; the era page claims 10-10).
static region). Reserve above the engine's floor = 2,058 − 209 = 1,849, ceiling 29,120 + 1,849
= 30,969; the deployed pair lands 458 MiB above that floor. The 2026-10-11 bound of 29,120
("31,856 − 1,710 outside both budgets − keep 1 GiB free") was a hand-picked reserve, not the
engine's wall. Boot with the explicit `--vram-weights-mib`/`--vram-kv-mib` pair, never
`--kv-cache-size-mib`; with the disk tier engaged the engine also forces ~9.6 GiB of weight
buffers + arena onto the weights side (~29.6 GiB/card effective).
engine's wall. Under that pin, boot with the explicit `--vram-weights-mib`/`--vram-kv-mib` pair,
never `--kv-cache-size-mib`; with the disk tier engaged the engine also forced ~9.6 GiB of weight
buffers + arena onto the weights side (~29.6 GiB/card effective). **Neither flag exists at 1.8.1:**
the same ceiling is enforced by the resolver, and passing the pair is a boot refusal.
- **At 1.8.1 the split belongs to the resolver, and it funds ~90 % of the worst case, never 100 %.**
The engine takes `--gpu-headroom-mib`, the cache bound (`--max-model-len` × `--max-num-seqs`) and
`--host-pool-mib`, prints `claimable → elastic → experts / kv cache` before allocating anything, and
sizes the expert plane from the model's own weight footprint plus slack — not from a number typed by
hand. Three boots of evidence:
| shape | demand | cache given | backing |
|---|---|---|---|
| 3 × 200,000, host pool 44,928 | 600,000 tok | 4.20 GiB | 93 % (2.8 sessions) |
| 3 × 200,000, host pool 45,568 | 600,000 tok | **4.20 GiB — byte-identical** | 93 % |
| 2 × 200,000, host pool 44,928 (live) | 400,000 tok | 2.76 GiB = 365,204 tok | 91 % (1.8 sessions) |
So `--max-num-seqs` is a **demand signal** for the resolver and an admission cap for the scheduler —
never a KV allocation — and no slot count fully funds the worst case. Growing host RAM by less than a
slab step (640 MiB) moved nothing: the expert plane's claim decides what is left for KV, so cache is
grown through slots × window or the dtype, and experts are grown by not leaving elastic pool idle.
`--host-pool-mib` is still load-bearing the other way — it is where the expert rows the cards cannot
hold get pinned, and under-sizing it has always been a boot failure (36,864 refused a boot in the fp8
era). The two-slot boot's own behavioural check: three concurrent conversations all answer `200` —
the third **queues** (`queue_cap: 16`), it is not refused; the 400 class is a single request over
`--max-model-len`, or three concurrent full-height conversations against a 1.8-session cache.
- **Idle KV VRAM is the expert cache's spillover — the price is paid twice.** `mover: 2275
slots can be lent out of the KV cache (2.69 GiB)`, `arena: lends 740 MiB while steps are <=
256 tokens`; the lend grows with the pool (2.69 GiB from 3.46, 6.08 from 6.912, 4.22 today)
256 tokens`; the lend grows with the pool (2.69 GiB from 3.46, 6.08 from 6.912, 4.22 in the pair era)
so a bigger KV pool steals weight residency from the slab AND from the lendable cache. Priors
on this card/model: stealing 2.14 GiB of weights to buy `MAX_MODEL_LEN 262144` cost 2.5 %
decode; the other direction (`--expert-vs-cache-ratio` 0.82 → 0.84, +2.5 GiB of weights) cost
@@ -239,18 +280,46 @@ vllm-radiance era (this table opens on 09-26; the era page claims 10-10).
- **MNBT 2048.** 2560 collapses decode to 2.6-7.7 t/s (MTP resonance); the w4 file tier streams
in place only at MNBT ≤ 2048 and the activation arena scales with the chunk (32768 → 33.3
GiB, refused).
- **Slots cost KV and rows, never speed.** `--max-num-seqs` carries no radiance constraint: it
adds 110 MiB of recurrent state per slot and declares the fused TP all-reduce over `seqs ×
(1 + spec)` rows, and `wht6` is bit-exact up to 25 rows (single-sequence decode and any step
of ≤ 25 rows is exact; only busier steps are lossy) — 3 × 3 = 9 today. Measured 2026-10-06:
seqs 2 and seqs 3 are statistically tied (30.9-33.1 t/s, inside the ±3 % floor). The "seqs 2
cannot boot" law belonged to the retired tcclaviger fork's `torch.compile` shape
specialization, and radiance booted seqs 2 on its first fp8-native start.
**Same era, separate law: a dequantize-to-compute path bills host RAM twice.** On the
EMULATION backend an offload request of **118 GiB** landed as **~238 GiB** of real host RAM —
the dequantized copy rides on top of the pinned weights, and the doubling is invisible in the
weight count. Read the engine log for `Using 'EMULATION' Mxfp4 MoE backend` before concluding
"doesn't fit"; the container-side procedure and the kernel gate are .
- **Decode pays for the chunk it shares a step with.** Census of the live engine taken at the
3-slot shape on 1.0.13 (`/stats` + `/metrics`, cumulative over the 2026-10-15 boot):
1,435,585 decode-only steps at **18.7 ms** each against 127,991 prefill+mixed steps
at **317 ms** — 60 % of engine wall-clock spent inside 8 % of the steps, because the
chunk is `min(remaining, checkpoint interval 2048, MNBT 2048)` = 2048 rows and every
decode row in that step waits the whole chunk. Day of Prometheus (120 s step, 721
samples): `decode_tokens_per_second` median **13.3 t/s** in the 56 % of samples where
`prefill_tokens_per_second` ≥ 1000, against 106-240 t/s in the light-prefill buckets;
bucketed by mean step time it is 128.7 t/s at 0.02-0.05 s steps and 12.0 t/s at
0.15-0.4 s. The stall is **during**, not after — decode in windows 2-14 min after a
busy-prefill window sits at the idle baseline (58-64 vs 77-85 t/s, the residual
confounded by co-scheduling), so no lingering expert-residency penalty at 2-minute
resolution, and `preemptions: 0` rules out preempt-and-re-prefill. Method:
[[LLM Performance Diagnosis]] §9. The law is shape-independent; the fix lives at 1.8.1 (next law),
and nobody has re-run the census since the migration — so the fix is ordered, **not verified**.
- **The knobs for it arrived with 1.8.1, and only one of them is on.** `--busy-prefill-chunk` caps the
shared chunk so a step with waiting decoders takes a smaller piece (live: `auto`, the engine picks;
the explicit value is 512), and `--prefill-interleave N` inserts N decode-only steps after a chunk
step taken beside decoders. `interleave` is deliberately **0** — the env comment holds it there until
the decode-vs-prefill buckets ask for it, and those buckets have not been read since the migration.
The vendor prices a 1024 chunk at about a fifth off prefill throughput to halve the stall; that is a
knob for choosing tomorrow's chunk, not a measurement of this shape. MNBT remains an arm and not an
edit: 2560 collapses decode through MTP resonance. Any chunk change re-scores draft acceptance before
it is trusted.
- **Slots cost KV, rows, and window — never speed.** `--max-num-seqs` carries no radiance constraint: it
adds ≈110 MiB of recurrent state per slot (the live `kv_full_backing` arithmetic prices them exactly),
declares the fused TP all-reduce over `seqs × (1 + spec)` rows, and `wht6` is bit-exact up to 25 rows
(single-sequence decode and any step of ≤ 25 rows is exact; only busier steps are lossy) — 2 × 3 = 6
today. Measured 2026-10-06: seqs 2 and seqs 3 are statistically tied (30.9-33.1 t/s, inside the ±3 %
floor). Measured 2026-10-19 at 1.8.1: the third slot's real price is **KV, not rows** — dropping it
moved the resolver's cache from 4.20 GiB to 2.76 GiB and the backing from 2.8 to 1.8 sessions, while
the ≈4 GiB of host RAM it freed came back unbudgeted, landing in elastic pool that turned into a bigger
mover lend instead of cache (resolver law above). The old "seqs 2 cannot boot" law belonged to the
retired tcclaviger fork's `torch.compile` shape specialization, and radiance booted seqs 2 on its first
fp8-native start.
- **A dequantize-to-compute path bills host RAM twice.** On the EMULATION backend an offload request
of **118 GiB** landed as **~238 GiB** of real host RAM — the dequantized copy rides on top of the
pinned weights, and the doubling is invisible in the weight count. Read the engine log for
`Using 'EMULATION' Mxfp4 MoE backend` before concluding "doesn't fit"; a page that says a payload
"does not fit" without that line has not read the runtime.
- **One encoding per expert table** (`rad_arch_declare`): a whole expert kept bf16 is the only
thing legal beside the table, so a split tier (down=fp8, gate_up=w4) is unservable.
- **Descriptor-selection law.** The plugin is picked by the exact `(arch_id, quant descriptor)`
+52 -1
View File
@@ -197,7 +197,16 @@ created it — earlier topologies in .
`chat_template_kwargs.preserve_thinking`, which decided whether `<think>` blocks stayed in recorded
assistant messages. Sampler defaults 1.0/0.95/20 are server-side, so an absent temperature is NOT
greedy (halogen 0.1.4 and w4b decoded greedily); unknown sampling fields are a 400, out-of-range
values are refused not clamped, and `response_format` is unsupported.
values are refused not clamped.
- **`response_format` works, but only greedy** (measured 2026-10-19 on this pin): `json_schema` and `json_object`
both answer schema-valid JSON at `temperature: 0`; at any sampled temperature both draw `400 "response_format
json_schema / json_object decodes greedy in this release: send temperature 0 (or omit it). Sampling under a schema
is not implemented yet"`. **Omitting `temperature` does not save you** — the sampler default is server-side 1.0, so
"absent" means sampled. Clients that pattern-match that string for "endpoint cannot do schemas" (esperanto's
`is_json_schema_unsupported_error` fires on the words *not implemented*) downgrade to `json_object`, draw the same
400, and fail outright; a structured-output consumer MUST send `temperature: 0`. Escape hatch that keeps sampling:
ask for JSON in the prompt and send no `response_format` — the model returns parseable JSON anyway, and the
tolerant client-side extractor handles a prose wrapper.
- Over-ceiling prompt → 400 naming the exact overage, rejected by preflight in <1 s, so probing the
ceiling is free; over-cap `max_tokens` → 400 naming the cap; `max_tokens`, `max_completion_tokens` and
`max_output_tokens` all work and must agree.
@@ -210,6 +219,48 @@ created it — earlier topologies in .
- Deploy waits for :7770 to be free: a rootless container binds it at CREATE and exits 126, and a
leftover Quadlet beside the root unit guarantees the race.
## The NPU lane (present in the image, not enabled)
XDNA2 executes only vendor-precompiled AIE partition programs — that is the wall the TTS attempt died at
([[Frank TTS]]) — and flash-server ships those programs. `HALOGEN_NPU_MODELS=<names>` starts a second engine **in the
same container behind the same port**, and a request picks one by its `model`; leave the variable out and the server
is exactly what runs today. The pinned 0.16.2 image already carries the five-program catalog
(`/opt/halogen/npu/models.txt`: `decider-0.8b`, `qwen3-embedding-0.6b`, `qwen3-reranker-0.6b`, `qwen3guard-gen-0.6b`,
`qwen3.5-2b`) and `docs/NPU.md` at tag `v0.16.2` documents it, so **`/v1/embeddings` is one env var and three mounts
away — no runtime bump needed.** Wider catalog: `qwen3-embedding-4b` and `decider-4b` from 0.17.4, `embeddinggemma-2`
(text ≤ 8,192 tokens, pictures and audio in one vector space) and `wemm-embedding-4b` from 0.18.3.
Embedding contract on that lane: `POST /v1/embeddings`, `model: "qwen3-embedding-0.6b"`, `input` a list of **texts**
(not token ids), 1024 unit-length floats back, the Qwen instruct prefix goes inside the string
(`Instruct: …\nQuery:…`), `base64` accepted, and LangChain's `OpenAIEmbeddings` needs
`check_embedding_ctx_length=False`.
Four prerequisites the trio does not satisfy today, from the vendor's own host list — and this box is one of the two
configurations they test (Ubuntu 26.04's XRT 2.21 `libxrt2`+`libxrt-npu2`, the kernel's `amdxdna`, `iommu=pt`):
1. `--device /dev/accel/accel0` plus `--group-add keep-groups` (the pattern ComfyUI uses for `/dev/kfd`; `dank` is
already in `render`).
2. The host's XRT reachable inside: there is **no `/opt/xilinx/xrt`** here — distro packages put the three files the
container needs (`libxrt_coreutil.so.2`, `libxrt_core.so.2`, `libxrt_driver_xdna.so.2`) in
`/usr/lib/x86_64-linux-gnu`, where all three exist.
3. **The GPU fabric clock held at its top level, or the engine refuses to start.** GPU and NPU work share the die:
while `pp_dpm_fclk` is changing speed, concurrent GPU work can hang the machine and corrupt NPU results. The
engine holds the clock itself only when it runs as root with `/sys` mounted; `frank-dragoon` is a **rootless**
`podman run` under `User=dank`, so the path is the vendor's root host unit (`deploy/host/halogen-fabric-clock`) —
which is exactly the `pp_dpm_fclk` level-7 pin deferred in [[dankclaw-iommu-translation-off]] with the words "it
goes in when an NPU endpoint actually exists". Held: about a watt. Unheld: the hang. Measured 2026-10-19 nothing
is held (fabric DPM lists 400–2000 MHz with no forced level, graphics clock sitting at 857 of 2900 MHz).
4. `HALOGEN_DOWNLOAD=peonist-ai/halogen-qwen3.8-flash-next` on a boot with a **writable** `/models/npu` — the payload
mount is `:ro` by law, so the programs land via a throwaway writable run or a dedicated read-write bind for that
one subdirectory; the vendor notes a read-only volume works but re-runs the conversion at every start.
Price: the programs and their weights sit in host RAM beside the 62.9 GiB of locked weights ("a few GB in all" for
the default set; ~1.1 GiB for `embeddinggemma-2`), and **the Flash model decodes slower while the NPU works** — same
memory bus, same power budget. Under this page's RAM law the question is pool versus n-gram table, so read the boot
ledger after the first NPU boot instead of modelling it. If the lane is opened, prefer **0.18.8 or newer**: that
release budgets NPU memory before asking for it, fixing kernel errors from several NPU models under a concurrent mix
**on the stock kernel driver** — the driver this box runs.
## Files
- `scripts/frank-dragoon.env` · `scripts/serve-frank-dragoon.sh` (gates, RAM pin-gate wait, foreground
+35 -9
View File
@@ -1,6 +1,6 @@
---
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."
tags:
- llm
@@ -10,10 +10,10 @@ tags:
- gguf
- rtx3090
- 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**
(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…` |
| 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 |
| 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) |
| 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)
`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_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`):
`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]`
@@ -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 |
|---|---|---|---|---|---|
| 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 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` |
@@ -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.*`.
- **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.
- **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.
- 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.
- **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.
- `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
@@ -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
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).
- `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/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).
- `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/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`.
+1 -1
View File
@@ -35,7 +35,7 @@ the endpoint's **Eras** table, and the numbers a retired era measured are gone.
| [[GGUF Compatibility]] | Answering whether an engine loads a GGUF from the file header alone, and pinning a fork build that can. |
| [[Heterogeneous Split Triage]] | Triage before splitting across unequal accelerators: link characterisation, real bottleneck, what placement never buys. |
| [[Llama Cpp Serving Sizing]] | Measurement-first sizing for llama.cpp shapes: slot pools against a headroom band, per-device accounting, prompt cache. |
| [[LLM Performance Diagnosis]] | Probing why a served model is slow: the flat-depth probe, offload duty-cycle arithmetic, and profiler attribution. |
| [[LLM Performance Diagnosis]] | Probing why a served model is slow: the flat-depth probe, offload duty-cycle arithmetic, and step-shape attribution. |
| [[Model Swap and Rollback]] | The model-swap discipline: fit preflight, fetch verification, build and flag checks, live validation, three-pin revert. |
| [[PCIe Link and Atomics]] | Link probing, the streaming-versus-compute ceiling arithmetic, and the atomics wall that decides multi-GPU collectives. |
| [[Quantization by Hardware Architecture]] | This page maps LLM quantization formats to the GPU and CPU architectures that support them. |
+2 -2
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. |
| [[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 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. |
| [[Gluetun]] | Gluetun is a lightweight VPN client container. |
| [[Hermes]] | Hermes is the self-improving AI agent from Nous Research. |
@@ -154,7 +154,7 @@ is a locator: one row, one clause, no numbers.
| [[GGUF Compatibility]] | Answering whether an engine loads a GGUF from the file header alone, and pinning a fork build that can. |
| [[Heterogeneous Split Triage]] | Triage before splitting across unequal accelerators: link characterisation, real bottleneck, what placement never buys. |
| [[Llama Cpp Serving Sizing]] | Measurement-first sizing for llama.cpp shapes: slot pools against a headroom band, per-device accounting, prompt cache. |
| [[LLM Performance Diagnosis]] | Probing why a served model is slow: the flat-depth probe, offload duty-cycle arithmetic, and profiler attribution. |
| [[LLM Performance Diagnosis]] | Probing why a served model is slow: the flat-depth probe, offload duty-cycle arithmetic, and step-shape attribution. |
| [[Model Swap and Rollback]] | The model-swap discipline: fit preflight, fetch verification, build and flag checks, live validation, three-pin revert. |
| [[PCIe Link and Atomics]] | Link probing, the streaming-versus-compute ceiling arithmetic, and the atomics wall that decides multi-GPU collectives. |
| [[Quantization by Hardware Architecture]] | This page maps LLM quantization formats to the GPU and CPU architectures that support them. |
+4 -4
View File
@@ -32,13 +32,13 @@
"name": "open-notebook",
"category": "user",
"section": "AI",
"description": "Podcast generator — notes in, spoken episode out: script written by frank-zealot, voices rendered by frank-tts. No public route; LAN UI only once it is deployed.",
"url": "",
"description": "Podcast generator — notes in, spoken episode out: script written by frank-zealot, voices rendered by frank-tts. Guarded twice: Authentik at the edge, then its own login.",
"url": "http://notebook.franksdank.cloud",
"docs": "",
"probe": "127.0.0.1:8502",
"method": "http",
"wip": "down",
"showLink": false,
"wip": false,
"showLink": true,
"art": "/img/apps/mono-on.svg"
},
{
+40 -7
View File
@@ -331,13 +331,40 @@ FAKE
[ "$(argv --port)" = "8880" ]
[ "$(argv --lang)" = "auto" ]
[ "$(argv --max-batch)" = "1" ]
# The two flags that must never appear: there is no thread count to set (it
# comes from hardware_concurrency()/2), and naming a device on an image with
# every GPU backend compiled out is only a new way to be wrong.
! grep -qx -- '--device' "$ARGS_FILE"
# The device the env names has to reach argv BY NAME: the image ships the
# Mesa ICD and no NVIDIA one, so Vulkan0 is the gfx1151 and cannot drift onto
# another card the way an index would.
[ "$(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"
}
@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" {
cat > "$TEST_TMP/fake-tts-server" <<'FAKE'
#!/usr/bin/env bash
@@ -627,17 +654,23 @@ PY
@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
# 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
# are what the 2026-10-18 trial ran.
# copy of upstream on purpose. -j 8 is 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 -qF '5916bf7c79dbb4e66fc059cdc507a79a23a28bb4' "$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
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_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; }
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"
# OpenMP on means the runtime stage needs the OpenMP runtime, and the probe
# 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
# 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
# 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
# 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.
MODEL_STUB="$(basename "$(sed -nE 's/^MODEL_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"
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() {
@@ -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")
METRICS=$(sed -nE 's/^METRICS=([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
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"
}
@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
# 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
@@ -127,6 +133,7 @@ run_stage() {
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 '^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" {
@@ -135,7 +142,7 @@ run_stage() {
# would be gating on a file nobody reads — and a missing projector would
# ship a blind endpoint.
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%% *}"
dep_v="${pair##* }"
path="$(sed -nE "s/^${env_v}=(.+)$/\1/p" "$ENV_FILE")"
@@ -367,6 +374,45 @@ run_stage() {
! 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" {
# general.sampling.* (temperature / top_p / top_k / min_p / repeat_penalty)
# rides in the checkpoint and the engine reads it itself; presence penalty
@@ -406,23 +452,33 @@ run_stage() {
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"
serve_argv
[ "$status" -ne 0 ]
[[ "$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"
serve_argv
[ "$status" -ne 0 ]
[[ "$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 'LLM_ZEALOT_RUNTIME' "$REMOTE_DEPLOY"
grep -q 'LLM_ZEALOT_MODEL_FILE' "$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 'systemctl enable frank-zealot.service' "$REMOTE_DEPLOY"
grep -q '127.0.0.1:7771/health' "$REMOTE_DEPLOY"
+71 -31
View File
@@ -17,6 +17,7 @@ NET="$REPO_ROOT/$UNIT_DIR/open-notebook.network"
ENVF="$REPO_ROOT/scripts/open-notebook.env"
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
REMOTE="$REPO_ROOT/scripts/deploy-remote.sh"
CADDY="$REPO_ROOT/dankclaw/Caddyfile"
# The host-side path the units read (they run on Dankclaw, not here), spelled
# out once so the ordering test below compares against a real constant.
HOST_TUNABLES="EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env"
@@ -68,22 +69,58 @@ DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
! grep -qE '^PublishPort=.*:8000(:|$)' "$APP"
}
@test "the app publishes its UI and moves the API off the host port seerr holds" {
# onb_vhost — the host Caddy routes to the UI's 8502. Derived, never restated:
# the API origin and the homepage link have to agree with whatever the Caddyfile
# actually routes, so renaming the vhost there moves this seam instead of
# leaving it blessing a stale name.
onb_vhost() {
awk '
/^[[:space:]]*@[A-Za-z0-9_-]+[[:space:]]+host[[:space:]]/ { h = $3 }
/^[[:space:]]*reverse_proxy[[:space:]]+192\.168\.0\.143:8502$/ { if (h) { print h; exit } }
' "$CADDY"
}
@test "the app publishes the UI for the guard chain and keeps the API off the LAN" {
# 8502 is the single upstream the post-auth listener dials, so it is the
# only port that has to answer on a LAN interface.
grep -qxF 'PublishPort=0.0.0.0:8502:8502' "$APP"
[ "$(grep -cE '^PublishPort=0\.0\.0\.0:' "$APP")" = "1" ] || {
echo "the UI port is not the only wildcard publish: $(grep -E '^PublishPort=' "$APP")"; false; }
# seerr already binds 127.0.0.1:5055 on this host, so the API cannot leave
# the container on the host port upstream uses.
publish="$(grep -E '^PublishPort=[^:]+:[0-9]+:5055$' "$APP")"
[ -n "$publish" ] || { echo "no PublishPort mapping to the API's 5055"; false; }
mapping="${publish#PublishPort=}" # e.g. 0.0.0.0:5056:5055
host_port="${mapping#*:}"
host_port="${host_port%%:*}"
[ "$host_port" != "5055" ] || {
echo "the API is published on host 5055, which seerr already holds"; false; }
# The frontend auto-detects the API as <host it was opened on>:5055, so a
# remap is only correct if API_URL names the published port.
# the container on the port upstream uses; and it needs no outward face at
# all, because the image's Next.js layer rewrites /api/* to it. Loopback is
# where the deployer's health gate dials it.
grep -qxF 'PublishPort=127.0.0.1:5056:5055' "$APP"
# One origin serves UI and API, so API_URL names the guarded vhost rather
# than a host:port. Left to itself the frontend auto-detects `<host>:5055`;
# a LAN-origin API_URL takes the browser around the guard chain, where its
# authenticated session does not ride.
api_url="$(sed -n 's/^API_URL=//p' "$ENVF")"
[ "$api_url" = "http://192.168.0.143:${host_port}" ] || {
echo "API_URL ($api_url) does not name the published API port ($host_port)"; false; }
vhost="$(onb_vhost)"
[ -n "$vhost" ] || { echo "no Caddy route to the UI port was found"; false; }
[ "$api_url" = "https://${vhost}" ] || {
echo "API_URL ($api_url) is not the guarded origin https://${vhost}"; false; }
# Same-origin traffic needs no CORS at all, but while CORS_ORIGINS is unset
# the app warns and defaults to `*` in front of every episode's audio.
cors="$(sed -n 's/^CORS_ORIGINS=//p' "$ENVF")"
[ "$cors" = "https://${vhost}" ] || {
echo "CORS_ORIGINS ($cors) is not the single guarded origin https://${vhost}"; false; }
}
@test "the vhost is guarded and the app login stays behind it" {
vhost="$(onb_vhost)"
# The vhost itself proxies the WHOLE request to Anubis — an unmatched path
# must never reach the app unchallenged.
awk -v v="$vhost" '
$0 == v " {" { inblock = 1; seen = 0 }
inblock && /reverse_proxy anubis:8923/ { seen = 1 }
inblock && /^}/ { exit }
END { exit !seen }
' "$CADDY" || { echo "$vhost does not proxy everything through Anubis"; false; }
# And the app's own password is still required, so a route that ever bypasses
# the chain does not hand out the episode library: the deployer refuses to
# start the units while the secret file leaves it blank.
grep -qF 'OPEN_NOTEBOOK_PASSWORD' "$ENVF"
}
@test "the pair shares one network and the app finds the database by container name" {
@@ -170,18 +207,19 @@ DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
! grep -qE '^Exec=.*--(pass|user)' "$DB"
}
@test "both health probes are argument-only and name real paths" {
for unit in "$APP" "$DB"; do
cmd="$(sed -n 's/^HealthCmd=//p' "$unit")"
[ -n "$cmd" ] || { echo "$(basename "$unit") has no HealthCmd"; false; }
# The quadlet generator splits HealthCmd on spaces, so anything needing
# a shell (quotes, pipes, &&) would be silently mis-sent.
! printf '%s' "$cmd" | grep -qE '["&;|`]'
done
# The API's own route, exempt from its password middleware upstream.
@test "the database probe can run in a shell-less image and the API probe names the health route" {
# podman wraps a BARE HealthCmd as CMD-SHELL (measured on this host's podman
# 5.7), and the SurrealDB image is distroless — no /bin/sh — so a bare
# command there is a probe that can only ever fail: the database answers
# perfectly while the health status stays `unhealthy` and the deploy gate
# times out. Only the exec-form JSON array runs in that image.
db_cmd="$(sed -n 's/^HealthCmd=//p' "$DB")"
[ "$db_cmd" = '["/surreal","is-ready"]' ] || {
echo "database probe is not exec-form JSON: $db_cmd"; false; }
# The API image ships curl and a shell, so a bare command is fine there; it
# must name the route that is exempt from the password middleware, on the
# CONTAINER port (the probe runs inside the netns).
grep -qxF 'HealthCmd=curl -fsS http://127.0.0.1:5055/health' "$APP"
# The distroless database image has no shell and no curl; it ships a probe.
grep -qxF 'HealthCmd=/surreal is-ready' "$DB"
}
@test "the deployer gates open-notebook on the health path that actually answers" {
@@ -244,21 +282,23 @@ DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
grep -qF 'dankclaw:open-notebook|' "$REPO_ROOT/scripts/deploy.sh"
}
@test "the homepage card exists and stays hidden until the service is deployed" {
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
@test "the homepage card links the guarded vhost now that the service is live" {
python3 - "$REPO_ROOT/shared/homepage/services.json" "$(onb_vhost)" <<'PY'
import json, os, sys
data = json.load(open(sys.argv[1]))
vhost = sys.argv[2]
cards = [s for s in data if s["name"] == "open-notebook"]
assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}"
card = cards[0]
assert card["category"] == "user" and card["section"] == "AI", card
assert card["probe"] == "127.0.0.1:8502", card["probe"]
assert card["method"] == "http"
# Not deployed yet: the card is reported wip-down and carries no link, so the
# page shows no dead button (the Seerr precedent).
assert card.get("wip") == "down", card.get("wip")
assert card.get("showLink") is False, card.get("showLink")
assert not card.get("url"), "a hidden card must not carry a url"
# Deployed and guarded: the card is live and its link is the vhost, so the
# button on the page reaches the app through the guard chain. Card links carry
# the fleet's http:// form everywhere; Caddy answers the redirect to HTTPS.
assert not card.get("wip"), f"card should be live, got wip={card.get('wip')!r}"
assert card.get("showLink") is True, card.get("showLink")
assert card.get("url") == f"http://{vhost}", card.get("url")
art = card["art"].lstrip("/")
assert os.path.exists(os.path.join(os.path.dirname(sys.argv[1]), art)), art
PY