Compare commits

..
Author SHA1 Message Date
Frank e030486ce6 docs: pin cwd meaning in attribution footer
Mirror of command-center clarification: first dogfooding round stamped the
build worktree instead of the session's own working directory. Rule text
now says which one, and to note the build worktree in the body.
2026-10-18 16:41:21 -07:00
Frank d89ae23195 docs: attribution footer for machine-opened issues and PRs
Mirror of dank/command-center's rule (there: PR off docs/session-trailer-rule).
Every agent-opened issue/PR body ends with cwd + omp session id so machine-filed
artifacts trace back to the box and session that produced them.

Refs #11 (command-center tracker)
2026-10-18 16:32:59 -07:00
dank f206ddfde5 Merge pull request 'Docs: codify trunk-based git workflow in AGENTS.md' (#6) from chore/trunk-based-git-workflow into main 2026-10-18 15:43:29 -07:00
Frank bf29518a15 Docs: PRs get opened in Frank's browser the moment they're filed
Fleet law alignment with dank/command-center: no PR-hunting in Gitea.
tests/run.sh ALL TESTS PASSED before commit.
2026-10-18 15:41:14 -07:00
Frank 169dbe2f38 Docs: codify trunk-based git workflow in AGENTS.md
The section mandated a source-test-push loop but never stated a branch
regime - agents pushed straight to main. Fleet alignment (operator
decision 2026-10-18, mirrors dank/command-center): main is the only
long-lived branch; branch fresh off origin/main per work item; PRs
target main with --no-ff; merge only on Frank's explicit OK; tags on
main. Their tests/run.sh suite: ALL TESTS PASSED before push.
2026-10-18 15:27:53 -07:00
Frank 4a6d5b8191 docs(log): frank-tts phase 1 landed as PR #4; PTQ1_0 provenance line finished
The dragoon-window provenance bullet still said the zealot arm owed a
measurement — it does not: the numbers are recovered and in the fetch script and
runbook. Adds the frank-tts phase-1 entry (naming law, port 7772, voice-pack law,
384/384 suite, PR #4 merged as 49c4e19, and the measured Gitea merge-field law).
2026-10-18 14:12:32 -07:00
dank 49c4e198fd Merge pull request 'feat(frank-tts): cloned-voice speech endpoint (phase 1)' (#4) from wt/20261017-181502 into main 2026-10-18 14:08:13 -07:00
Frank 31266fb7f3 feat(frank-tts): cloned-voice speech endpoint — OpenAI-shaped facade, voice packs by name
Phase 1 of the voice-over goal (Gitea issue #2). One stdlib-only HTTP facade in
front of whichever TTS engine wins the engine decision: POST /v1/audio/speech
with voice=<name> answers audio in THAT voice, where a name is real exactly when
/voices/<name>/reference.wav exists — an unknown name is a 400 listing the
voices that are, never a silent fallback (a wrong-voice endpoint stays wrong for
weeks). voice_id works as the alias the omp harness sends; response_format=mp3
is really mp3 (ffmpeg re-encode); oversized input is refused with 414 instead of
half-narrated; /health reports the backend and the resolved voice list.

Two backends behind the one surface, chosen by TTS_BACKEND: omni-http forwards to
an OpenAI-shaped upstream carrying the voice as base64 reference_audio, and
llama-tts-cli runs a local binary per request with --tts-speaker-file at nice 19
so narration never starves the text endpoints. Both are driven in the seam
through stubs — no GPU, no containers.

Named frank-tts, a first-class fleet model endpoint: port 7772 in the 7770/7771
family, alias naming per the StarCraft law, rootless boot-persistent self-healing
quadlet with a read-only /home/dank/voices mount. Voice-pack law recorded in the
env header: reference.wav PLUS the engine's own speaker artifact (required, so a
voice is reusable in other runtimes), plus manifest.json provenance with offsets
always past the first 10 s of the source file.

Not deployed: the engine decision, the pack builder, deployer/homepage/Prometheus
registration and the omp models.yml runner row are phase 2.
2026-10-18 14:06:06 -07:00
Frank 06a9cb60eb docs(frank-zealot): PTQ1_0 was ordered, measured and approved — not an unfinished arm
Frank's 02:36 instruction (archive 01a14c93) ordered the 27B PTQ1_0 arm at
2 x 200,704 with the Q8 mmproj and batch/ubatch 3072/1536; it served
production 03:14:39-03:38:57 (pid 191358, journal type ptq1_0: 402 tensors).
Recovered its numbers from that process's own print_timing lines: ~48 t/s
single-stream decode, 25-27 t/s per stream at two streams, prefill 1,295 t/s
at 30,637 -> 943 at 97,856 -> 801 at 141,365. It bought the promised 1,201 MiB
and lost 37% of decode against the PQ2_0 record, so the publisher's Ampere
warning generalizes to GA102; Frank scratched the 27B plan at 03:33 for the 9B.

Every 'unfinished experiment' claim is re-keyed, and the AGENTS.md law gains
the durable lesson: a bench verdict that never reaches the repo is
indistinguishable from a rogue arm.
2026-10-18 13:50:19 -07:00
Frank e7841bf9d6 docs(fleet): halogen owns no timing instruments; warm-cache cost measured after the pool raise
Post-deploy probe on frank-dragoon: one 101,987-token prompt through
/v1/chat/completions took 81.5 s cold (1,251 t/s of prefill) and 0.3 s on two
repeats — HALOGEN_CACHE_INPLACE=1 keeps the KV rows in the pool, so doubling the
pool evicted nothing the prompt cache needed. The RAM price is still real:
MemAvailable 26 -> 22 GiB.

Census of the same engine's surface corrected the Benchmarking Policy, which
quoted llama.cpp instruments at a halogen endpoint: frank-dragoon answers
/health, /metrics, /v1/models and POST /v1/chat/completions and 404s /ping,
/props, /completion and /tokenize, and no response carries Server-Timing. The
/completion `timings` object and the ttft;dur= header are frank-zealot's; on
frank-dragoon the instrument is wall clock plus usage.prompt_tokens.
2026-10-18 13:36:35 -07:00
Frank c0759492e6 docs(frank-dragoon): the 401,408-position pool is live; every figure re-keyed to the boot ledger
Deployed 2026-10-18 (e2e 33/33). The boot ledger prints
`reserving the KV pool, 401408 positions (about 11.0 GiB)` and
`62.9 GiB of weights locked … 11.0 GiB of KV pool, 5.7 GiB of working memory,
79.6 GiB in all`, so the ~10.8 GiB that every page quoted from the
~28 KiB/position arithmetic is replaced by the reservation the engine actually
makes (11.0) and its preflight bill (11.3). Host MemAvailable fell 26 -> 22 GiB
across the raise: the FLAGS.md collision (pool RAM is n-gram page cache) paid in
full, so the warm prompt-cache ratio is flagged as the number to watch.

All "committed but not yet deployed" clauses retired: AGENTS.md topology row,
KV-pool law and memory ledger, frank-dragoon.env lore, both index pages, the
iommu page, and the runbook chronology (third production boot recorded).

The sweep also caught the LLM index still describing frank-zealot as
Bonsai-2-27B at 2 x 200,704 in its intro and topology diagram - re-keyed to the
live 9B: 8 full-attention + 24 Gated DeltaNet + blk.32 MTP, 3 x 262,144,
tensor type 141.
2026-10-18 13:33:04 -07:00
Frank 695a5f3281 fix(frank-zealot): key the ledger to the production boot; record the arm drop-in hijack
The cutover deploy's FIRST boot was not the shipped payload: a stale
frank-zealot.service.d/ptq1.conf from the bench window still pointed
FRANK_ZEALOT_ENV_FILE at /tmp/zealot-arm-current.env, so the unit booted the
unmeasured 27B PTQ1_0 arm (3 slots of 133,888, 21,188 MiB) while /health
answered 200, /v1/models echoed the alias and the e2e gate passed 33/33.
Drop-in retired to ~/.AGENTTRASH/20261018-zealot-ptq1-arm/; the 13:19 restart
is the shape the docs now quote (n_ctx_slot 262144, KV 13,056.00 MiB,
settled 17,922/6,203).

Corrections the production ledger forced:
- RS buffer is 150.75 MiB (3 cells) on the shipped spec-off shape; 452.25 MiB
  belongs to the SPEC-ARMED arms, where MTP depth 2 bills three seqs per slot.
  The drafter triples the recurrent state on top of its 3,072 MiB of f16 KV.
- Decode measured 168.4-168.7 t/s single-stream and ~300 t/s aggregate over
  three streams (102.8 each). Wall-clock timing of a generation that stops on
  its own reads 4x low (72 t/s for a 24-token countdown) - measure with
  ignore_eos and read the engine's timings.
- OpenWebUI stores both decoration values inside JSON columns (params/meta),
  and frank-dragoon's context.length had drifted to 262,144 against its own
  200,704 ceiling; both re-keyed in the live DB.

New law in AGENTS.md: after deploying a recently-benched LLM service, check
DropInPaths, then /proc/<pid>/cmdline, then the ledger. Health checks cannot
see the payload.
2026-10-18 13:27:27 -07:00
16 changed files with 1371 additions and 114 deletions

No files matched your search

+77 -26
View File
@@ -26,8 +26,8 @@ merge absorbed the former `vllmScripts` repo, history preserved).
| Target | Service | Runtime | Model | Port | | Target | Service | Runtime | Model | Port |
|--------|---------|---------|-------|------| |--------|---------|---------|-------|------|
| Dankserver | `frank-archon` | **radiance** (StillDeadcode, **pin 1.0.13** digest-pinned `stilldeadcode/radiance@sha256:4c3f484564…`, plain `podman run`, root systemd unit) | **Publisher payload** (swap 2026-10-15, MODEL_FILE-only): `StillDeadcode/qwen3.8-next-flash-fp8-iq4r-moe` → **`qwen3.8-next-flash-fp8-iq4r-moe.rad`** (121,969,901,568 B = **116,322 MiB**, Hub-LFS sha256 `0af5e962…` verified at fetch by `scripts/download-frank-archon-iq4r.sh`) — the OFFICIAL Qwen3.8-Flash-Next bf16 checkpoint rad-converted by the engine's author: routed experts as **4-bit codes into a 16-level non-uniform codebook** (128-pt Walsh–Hadamard rotation, GPTQ on a 10M-token calibration + 3 coordinate-descent sweeps, E4M3 scales per 64 weights), **10 whole bf16-protected experts/layer**, int8 W8A8 trunk/lm_head, fp8 HC mixtures, fp8 fixed-scale PLE n-gram (47.7 GiB), **MTP head + bf16 vision tower in the container**; card KL 0.0789 mean all-tokens / top-1 92.85 % vs BF16. **Base-alignment (censored) model** — the abliterated family lives on frank-dragoon. Shape carried VERBATIM from the 2026-10-13 record: **3 × 200,000 fp8-KV** (`--max-num-seqs 3`), **25,600 MiB slab + 5,120 MiB pool = 30,720 of the 30,969 MiB claimable**, 44 GiB/rank pinned host pool, PLE pinned to RAM, MTP depth 2, MNBT 2048, Froggeric v22.5 template. Decode/prefill ladders in the runbook are the **w4dn8 record** (the bar to beat; new-payload probes pending). Rollback: re-download the w4dn8 `.rad` from the HF mirror `frank8465/Swift-1.5-Qwen3.8-Flash-Next-GPTQ-W4A8-Radiance-AMD-R9700` (LFS sha256 `09dd9306…`) + revert the swap commit — the local copy was **purged 2026-10-17** (it was the last non-iq4r model on the host; the GLM-4.7 GGUF, the radiance kvcache tiers, and every stale compile cache went in the same purge, 763 GiB free). The fp8-native `.rad` + the raw Swift1.5 BF16 source were DELETED from disk 2026-10-15 (Frank's one-time rm, volume for this payload; rebuildable via the fetch/build scripts). | 7770 | | Dankserver | `frank-archon` | **radiance** (StillDeadcode, **pin 1.0.13** digest-pinned `stilldeadcode/radiance@sha256:4c3f484564…`, plain `podman run`, root systemd unit) | **Publisher payload** (swap 2026-10-15, MODEL_FILE-only): `StillDeadcode/qwen3.8-next-flash-fp8-iq4r-moe` → **`qwen3.8-next-flash-fp8-iq4r-moe.rad`** (121,969,901,568 B = **116,322 MiB**, Hub-LFS sha256 `0af5e962…` verified at fetch by `scripts/download-frank-archon-iq4r.sh`) — the OFFICIAL Qwen3.8-Flash-Next bf16 checkpoint rad-converted by the engine's author: routed experts as **4-bit codes into a 16-level non-uniform codebook** (128-pt Walsh–Hadamard rotation, GPTQ on a 10M-token calibration + 3 coordinate-descent sweeps, E4M3 scales per 64 weights), **10 whole bf16-protected experts/layer**, int8 W8A8 trunk/lm_head, fp8 HC mixtures, fp8 fixed-scale PLE n-gram (47.7 GiB), **MTP head + bf16 vision tower in the container**; card KL 0.0789 mean all-tokens / top-1 92.85 % vs BF16. **Base-alignment (censored) model** — the abliterated family lives on frank-dragoon. Shape carried VERBATIM from the 2026-10-13 record: **3 × 200,000 fp8-KV** (`--max-num-seqs 3`), **25,600 MiB slab + 5,120 MiB pool = 30,720 of the 30,969 MiB claimable**, 44 GiB/rank pinned host pool, PLE pinned to RAM, MTP depth 2, MNBT 2048, Froggeric v22.5 template. Decode/prefill ladders in the runbook are the **w4dn8 record** (the bar to beat; new-payload probes pending). Rollback: re-download the w4dn8 `.rad` from the HF mirror `frank8465/Swift-1.5-Qwen3.8-Flash-Next-GPTQ-W4A8-Radiance-AMD-R9700` (LFS sha256 `09dd9306…`) + revert the swap commit — the local copy was **purged 2026-10-17** (it was the last non-iq4r model on the host; the GLM-4.7 GGUF, the radiance kvcache tiers, and every stale compile cache went in the same purge, 763 GiB free). The fp8-native `.rad` + the raw Swift1.5 BF16 source were DELETED from disk 2026-10-15 (Frank's one-time rm, volume for this payload; rebuildable via the fetch/build scripts). | 7770 |
| Dankclaw | `frank-dragoon` | **halogen-flash-server 0.16.2** (`ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`, digest-pinned, plain `podman run` via `scripts/serve-frank-dragoon.sh`, root systemd unit) | Quat3rnion/halogen-qwen3.8-flash-next-v2-abliterated **`qwen38-flash-next-v2-abliterated.hgn`** (66,687,678,432 B ≈ 62.1 GiB resident, Hub LFS sha256 `b5bd925f…` verified at fetch — the **NATIVE 2026-10-11 v2 conversion** of orcarouter's pinned BF16, abliterated, NOT the writer transplant that carried this filename; the v2 `.hgn` format loads in NO other engine), **+ REQUIRED `qwen38-flash-next-ngram.hgn`** (51,200,246,144 B, peonist @5cc17cea) **+ vision tower** (897,916,416 B, @e053f488) — both **hardlinked** from the swift15 payload dir (the release publishes only the main file; the engine's own `niah` 18/18 + `ppl` 3.968 with mean KL 0.155 vs the swift15 logits certified the stock companions), **vision ON** (`image_url` parts only, ≥256 px probes), **MTP spec decode ALWAYS-ON (`HALOGEN_SPEC_ADAPT=0` — the adaptive default disables the drafter per request and HALVES decode)**, MTP head INSIDE the checkpoint (`HALOGEN_MTP_HEAD` unset), **TWO slots over ONE `HALOGEN_KV_POOL_POSITIONS=401408` pool (= 2 × ctx) at `HALOGEN_CTX=200704`** (pool RAISED from 200,704 on 2026-10-18 so BOTH slots can hold a maximum-length request at once; the parity play was frank-zealot's then-401,408 total in-flight context — zealot moved to 786,432 the same evening and dragoon's pool was NOT raised again, because every position is page cache the n-gram table loses; ~10.8 GiB reserved where 200,704 cost 5.5, 262,144 cost 7.2 and the 2026-10-06 pool measured 14.6; the ceiling does NOT move with it — 262,144 is the native wall and the engine refuses more without `HALOGEN_ROPE_YARN`), harness window 155,648 = ceiling − 45k estimate headroom, **server-side sampler 1.0/0.95/20** (peonist engine recipe — Quat3rnion publishes none; models.yml extraBody matches), coupled cap/timeout 65536/7200, prompt cache ON by default (warm 100k turn ≈ 2 s vs ≈ 88 s cold), **n-gram table disk-backed** (mmap page cache, `Pin::None`); measured vs the swift15 boot: cold prefill +12.6 %, decode 50.0 vs 46.8 t/s, NIAH 18/18 vs 17/18, PPL +2.3 %, thinking tokens ×1.9|7770| | Dankclaw | `frank-dragoon` | **halogen-flash-server 0.16.2** (`ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`, digest-pinned, plain `podman run` via `scripts/serve-frank-dragoon.sh`, root systemd unit) | Quat3rnion/halogen-qwen3.8-flash-next-v2-abliterated **`qwen38-flash-next-v2-abliterated.hgn`** (66,687,678,432 B ≈ 62.1 GiB resident, Hub LFS sha256 `b5bd925f…` verified at fetch — the **NATIVE 2026-10-11 v2 conversion** of orcarouter's pinned BF16, abliterated, NOT the writer transplant that carried this filename; the v2 `.hgn` format loads in NO other engine), **+ REQUIRED `qwen38-flash-next-ngram.hgn`** (51,200,246,144 B, peonist @5cc17cea) **+ vision tower** (897,916,416 B, @e053f488) — both **hardlinked** from the swift15 payload dir (the release publishes only the main file; the engine's own `niah` 18/18 + `ppl` 3.968 with mean KL 0.155 vs the swift15 logits certified the stock companions), **vision ON** (`image_url` parts only, ≥256 px probes), **MTP spec decode ALWAYS-ON (`HALOGEN_SPEC_ADAPT=0` — the adaptive default disables the drafter per request and HALVES decode)**, MTP head INSIDE the checkpoint (`HALOGEN_MTP_HEAD` unset), **TWO slots over ONE `HALOGEN_KV_POOL_POSITIONS=401408` pool (= 2 × ctx) at `HALOGEN_CTX=200704`** (pool RAISED from 200,704 and DEPLOYED 2026-10-18 so BOTH slots can hold a maximum-length request at once; the parity play was frank-zealot's then-401,408 total in-flight context — zealot moved to 786,432 the same evening and dragoon's pool was NOT raised again, because every position is page cache the n-gram table loses; **11.0 GiB reserved, measured on that deploy** (the preflight formula bills 11.3) where 200,704 cost 5.5, 262,144 cost 7.2 and the 2026-10-06 pool measured 14.6; the ceiling does NOT move with it — 262,144 is the native wall and the engine refuses more without `HALOGEN_ROPE_YARN`), harness window 155,648 = ceiling − 45k estimate headroom, **server-side sampler 1.0/0.95/20** (peonist engine recipe — Quat3rnion publishes none; models.yml extraBody matches), coupled cap/timeout 65536/7200, prompt cache ON by default (warm 100k turn ≈ 2 s vs ≈ 88 s cold), **n-gram table disk-backed** (mmap page cache, `Pin::None`); measured vs the swift15 boot: cold prefill +12.6 %, decode 50.0 vs 46.8 t/s, NIAH 18/18 vs 17/18, PPL +2.3 %, thinking tokens ×1.9|7770|
| Dankclaw | `frank-zealot` | **llama.cpp PrismML fork build** (vendor release `0.2.0-dev` build **10777**, commit `e3de71522`; root systemd unit, relinked — the ONLY loader for this payload, and since 2026-10-18 also the minimum build that pairs MTP with a quantized V cache) | `prism-ml/Ternary-Bonsai-2-9B-gguf` **PQ2_0 carrying the MTP head in the file** (2,675,410,720 B, Hub-LFS sha256 `c79fcdfa…` verified at fetch by `scripts/download-frank-zealot-bonsai9.sh`; `qwen35` hybrid: `block_count 33` = **32 trunk layers** (8 full-attention + 24 Gated DeltaNet at `full_attention_interval 4`) + the `blk.32` MTP block, 4 KV heads × 256 dim, native 262,144; `general.file_type 141` with **202 custom-type PQ2_0 tensors of 442** + `prism.hadamard.*` dequant metadata, which is what locks the runtime) + the publisher's own **Q8_0 mmproj** (624,229,664 B, sha256 `e0cb6f69…`; **Q8_0 is the publisher's requirement** for concurrent image requests — the BF16 tower can crash the server, and this endpoint runs three slots) for **vision**, on the RTX 3090, **THREE 262,144-token slots** (`--ctx-size 786432 --parallel 3` = every conversation at the trained window), **q8_0 K + q8_0 V** under flash attention (17 KiB/token over the 8 full layers = 13,056 MiB pool, ledger-exact; **f16 KV needs 26,112 MiB and cannot boot this card**; +452 MiB of recurrent state for the three DeltaNet cells), batch 4096 / **ubatch 2048** (measured CUDA optimum), `--log-verbosity 4`, **MTP OFF — measured, not because the head is absent**: the draft context bills its own f16 KV over the WHOLE pool (3,072 MiB) and at ubatch 2048 that squeeze halves decode (167.2 → 109.2 t/s); the documented MTP arm is `SPEC_TYPE=draft-mtp` + `UBATCH_SIZE=1024` → 213.5 t/s. Boot 17,876 used / 6,249 free; 167.2 t/s single-stream decode, 280.2 t/s aggregate across three concurrent streams, 2,877 t/s prefill at 106,418. Cutover 2026-10-18 from Ternary-Bonsai-2-**27B** PQ2_0 at 2 × 200,704 (payload + build 10754 tree on disk as rollback). | 7771 | | Dankclaw | `frank-zealot` | **llama.cpp PrismML fork build** (vendor release `0.2.0-dev` build **10777**, commit `e3de71522`; root systemd unit, relinked — the ONLY loader for this payload, and since 2026-10-18 also the minimum build that pairs MTP with a quantized V cache) | `prism-ml/Ternary-Bonsai-2-9B-gguf` **PQ2_0 carrying the MTP head in the file** (2,675,410,720 B, Hub-LFS sha256 `c79fcdfa…` verified at fetch by `scripts/download-frank-zealot-bonsai9.sh`; `qwen35` hybrid: `block_count 33` = **32 trunk layers** (8 full-attention + 24 Gated DeltaNet at `full_attention_interval 4`) + the `blk.32` MTP block, 4 KV heads × 256 dim, native 262,144; `general.file_type 141` with **202 custom-type PQ2_0 tensors of 442** + `prism.hadamard.*` dequant metadata, which is what locks the runtime) + the publisher's own **Q8_0 mmproj** (624,229,664 B, sha256 `e0cb6f69…`; **Q8_0 is the publisher's requirement** for concurrent image requests — the BF16 tower can crash the server, and this endpoint runs three slots) for **vision**, on the RTX 3090, **THREE 262,144-token slots** (`--ctx-size 786432 --parallel 3` = every conversation at the trained window), **q8_0 K + q8_0 V** under flash attention (17 KiB/token over the 8 full layers = 13,056 MiB pool, ledger-exact; **f16 KV needs 26,112 MiB and cannot boot this card**; +151 MiB of recurrent state for the three DeltaNet cells — the SPEC-ARMED arms print 452 MiB, because MTP depth 2 bills three seqs per slot), batch 4096 / **ubatch 2048** (measured CUDA optimum), `--log-verbosity 4`, **MTP OFF — measured, not because the head is absent**: the draft context bills its own f16 KV over the WHOLE pool (3,072 MiB) and at ubatch 2048 that squeeze halves decode (167.2 → 109.2 t/s); the documented MTP arm is `SPEC_TYPE=draft-mtp` + `UBATCH_SIZE=1024` → 213.5 t/s. Production boot 17,922 used / 6,203 free; **168.7 t/s** single-stream greedy decode (three runs, 168.4–168.7) and **~300 t/s** aggregate across three concurrent streams (102.8 each); 2,877 t/s prefill at 106,418 was the trial arm. Cutover 2026-10-18 from Ternary-Bonsai-2-**27B** PQ2_0 at 2 × 200,704 (payload + build 10754 tree on disk as rollback). | 7771 |
Retired endpoints (`frank-npu`, and every prior runtime) are in Retired endpoints (`frank-npu`, and every prior runtime) are in
the [runbook index](#runbook-index). The full swap history lives in the [runbook index](#runbook-index). The full swap history lives in
@@ -48,17 +48,37 @@ the [runbook index](#runbook-index). The full swap history lives in
pages (`serverAppConfigs-docs/log.md` carries the wave plan and results) pages (`serverAppConfigs-docs/log.md` carries the wave plan and results)
- `tests/` — bats suite + Playwright e2e - `tests/` — bats suite + Playwright e2e
## Git Workflow ## Git Workflow — trunk-based (fleet law, aligned 2026-10-18)
All edits flow through git. Never edit files on a remote machine directly. All edits flow through git. Never edit files on a remote machine directly.
1. **Source** the live config from the target machine `main` is the only long-lived branch: no `develop`, no release branches, no
2. **Commit** the fetched version here (redact secrets into `.env`) back-merges. Frank is the only dev; this repo and `dank/command-center` follow
3. **Edit** locally the same regime.
4. **Test** — `./tests/run.sh` must pass
5. **Commit and push** — origin is Gitea on Dankclaw 1. **Branch fresh** — `git fetch`, then branch off the current `origin/main`
6. **Pull** on the target (`deploy.sh` does this) (`<type>/<slug>`, type ∈ feat|fix|docs|chore). If main moves under you,
7. **Deploy** — `scripts/deploy.sh` plans and dispatches only changed services merge it into your branch — never rebase.
2. **Source** the live config from the target machine
3. **Commit** the fetched version here (redact secrets into `.env`)
4. **Edit** locally
5. **Test** — `./tests/run.sh` must pass
6. **Push the branch and open a PR against `main`** — origin is Gitea on
Dankclaw. Merge with `--no-ff`; **never merge a PR without an explicit,
current OK from Frank.** **The moment the PR is open, open it in Frank's
browser** (`xdg-open <PR-URL>` — a Firefox tab on the workstation); never
make him hunt for it in Gitea. Releases: tag `vX.Y.Z` straight on `main`.
7. **Pull** on the target (`deploy.sh` does this)
8. **Deploy** — `scripts/deploy.sh` plans and dispatches only changed services
**Attribution footer (issues and PRs)** — every issue or PR body you open MUST end with:
```
---
cwd: <working directory> · session: <omp session id>
```
Session id from `/status`; `cwd` is that session's own working directory — not the build checkout, when they differ (note the build worktree in the body instead). Machine-made artifacts stay attributable to the box and session that spawned them.
## Deployment ## Deployment
@@ -231,8 +251,12 @@ archive.
**STOP the production service BEFORE benchmarking** — these servers serve **STOP the production service BEFORE benchmarking** — these servers serve
real users, and a bench beside a live service starves the GPU and corrupts real users, and a bench beside a live service starves the GPU and corrupts
results. Use the runtime's own harness: `llama-bench` for the llama.cpp results. Use the runtime's own harness: `llama-bench` for the llama.cpp
endpoints; a live llama.cpp server also reports honest per-request numbers in the `/completion` `timings` object (prefill/decode rates, `draft_n`, `draft_n_accepted`), which is how frank-dragoon and frank-zealot were measured. Rolling your own bench endpoints; a live llama.cpp server (frank-zealot) also reports honest
requires asking first. per-request numbers in its `/completion` `timings` object (prefill/decode
rates, `draft_n`, `draft_n_accepted`), which is how frank-zealot was measured.
halogen has NO `/completion` path at all — frank-dragoon's rates come from
wall-clock around `/v1/chat/completions` plus the boot ledger. Rolling your own
bench requires asking first.
Scratch ports are not used on LLM hosts — a Scratch ports are not used on LLM hosts — a
test process cannot share the single GPU anyway; validate against the service test process cannot share the single GPU anyway; validate against the service
itself. itself.
@@ -250,9 +274,13 @@ real text made it 14 % *slower*. Use it for shape, not for A/B. For an A/B,
replay **real** text through the live server and read the `Server-Timing` replay **real** text through the live server and read the `Server-Timing`
header — but note the unit rides in the **name** half (`ttft;dur=402.485`), header — but note the unit rides in the **name** half (`ttft;dur=402.485`),
so splitting on `=` alone keys the dict `ttft;dur` and every lookup silently so splitting on `=` alone keys the dict `ttft;dur` and every lookup silently
returns nothing. Generate **at least 128 tokens** for the measurement: at 8 returns nothing. **That header is a llama.cpp feature**: halogen flash-server
tokens, `inter_token` is first-token noise and the run-to-run spread swamps 0.16.2 answers `/v1/chat/completions` with only `date`, `server` and
every real difference. `content-*` (verified 2026-10-18), and `/completion`, `/ping`, `/props` and
`/tokenize` are all 404 on it — on frank-dragoon the A/B instrument is
wall-clock around the POST plus `usage.prompt_tokens`. Generate **at least 128
tokens** for the measurement: at 8 tokens, `inter_token` is first-token noise
and the run-to-run spread swamps every real difference.
This covers **any GPU experiment, not just benchmarks** — including a bare This covers **any GPU experiment, not just benchmarks** — including a bare
2-rank RCCL/`torch.distributed` probe. On 2026-09-16 one such probe was run 2-rank RCCL/`torch.distributed` probe. On 2026-09-16 one such probe was run
@@ -407,10 +435,10 @@ MAXIMUM-length conversations DO coexist. The PER-REQUEST ceiling stays
`HALOGEN_CTX=200704`: the checkpoint's native window is 262,144 and the engine's `HALOGEN_CTX=200704`: the checkpoint's native window is 262,144 and the engine's
own 400 text refuses to exceed it without `HALOGEN_ROPE_YARN`, so "more context" own 400 text refuses to exceed it without `HALOGEN_ROPE_YARN`, so "more context"
bought here is CONCURRENCY, not a longer single conversation. ~28 KiB/position ⇒ bought here is CONCURRENCY, not a longer single conversation. ~28 KiB/position ⇒
the ledger reserves "about 10.8 GiB", where 200,704 cost 5.5, 262,144 cost 7.2 the 2026-10-18 deploy RESERVES **11.0 GiB** (`reserving the KV pool, 401408
and the 2026-10-06 shape's 524,288 measured 14.6; the engine's preflight FORMULA positions (about 11.0 GiB)`; the preflight formula bills 11.3 with block scratch
bills slightly more than the reservation (block scratch + ~115 MiB/slot of O(1) + ~115 MiB/slot of O(1) state), where 200,704 cost 5.5, 262,144 cost 7.2 and
state) — quote the reservation. the 2026-10-06 shape's 524,288 measured 14.6 — quote the reservation.
`HALOGEN_MAX_TOK=8192` is only the per-request DEFAULT (the `HALOGEN_MAX_TOK=8192` is only the per-request DEFAULT (the
harness sends at most its `maxTokens` 45056); `HALOGEN_MAX_TOKENS_CAP=65536` + harness sends at most its `maxTokens` 45056); `HALOGEN_MAX_TOKENS_CAP=65536` +
`HALOGEN_QUEUE_TIMEOUT=7200` are a coupled pair (FLAGS.md table). `HALOGEN_QUEUE_TIMEOUT=7200` are a coupled pair (FLAGS.md table).
@@ -428,10 +456,12 @@ stays mandatory. Thinking tokens COUNT against the budget (empty content +
"video". "video".
**Memory ledger** (121.2 GiB unified; the referee is the boot ledger): weights **Memory ledger** (121.2 GiB unified; the referee is the boot ledger): weights
62.9 (trunk 62.11 + vision 0.84) + KV ~10.8 + working 5.7 62.9 (trunk 62.11 + vision 0.84) + KV **11.0** + working 5.7
(`3.8 + 8.4 × MAX_TOK/16384`) ≈ **79.4 GiB in all**, the ~1.0 GiB prompt cache (`3.8 + 8.4 × MAX_TOK/16384`) = **79.6 GiB in all** (boot ledger, the
booked separately by the ledger, + `HALOGEN_HOST_RESERVE_GIB=20` → ~22 GiB 401,408-position deploy of 2026-10-18), the **1.3 GiB** prompt cache (12 entries
margin over baseline co-tenancy (the pool-equals-ctx boot printed 74.1 GiB with × 111 MiB at ctx 200704) booked separately by the ledger, + `HALOGEN_HOST_
RESERVE_GIB=20` → ~20 GiB margin over baseline co-tenancy (`MemAvailable` read
22 GiB right after the raise; the pool-equals-ctx boot printed 74.1 GiB with
~30 GiB of margin). The 51.2 GB n-gram table rides as mmap'd page cache beside ~30 GiB of margin). The 51.2 GB n-gram table rides as mmap'd page cache beside
the checkpoint (ledger: `mapped 51.2 GB, NOT pinned (Pin::None)`) — which is why the checkpoint (ledger: `mapped 51.2 GB, NOT pinned (Pin::None)`) — which is why
every pool position is a double cost: **RAM the pool takes is RAM that table every pool position is a double cost: **RAM the pool takes is RAM that table
@@ -494,9 +524,10 @@ so **q8_0 K + q8_0 V bills 17,408 B/token ≈ 17 KiB** = **13,056.00 MiB** at th
786,432 pool (ledger-exact) — exactly HALF the 27B's 34,816 B/token, which is 786,432 pool (ledger-exact) — exactly HALF the 27B's 34,816 B/token, which is
the whole reason three native windows fit where the 27B afforded only two the whole reason three native windows fit where the 27B afforded only two
shortened ones. **f16 K would bill 26,112 MiB and cannot boot on a 24,123 MiB shortened ones. **f16 K would bill 26,112 MiB and cannot boot on a 24,123 MiB
card.** The 24 DeltaNet layers bill a fixed recurrent state per SLOT — card.** The 24 DeltaNet layers bill a fixed recurrent state per SLOT — the
`RS buffer size = 452.25 MiB (3 cells, 32 layers, 3 seqs)` ≈ 150 MiB each — so production ledger prints `RS buffer size = 150.75 MiB (3 cells, 32 layers,
pool arithmetic alone under-prices the shape. 3 seqs)`, while the SPEC-ARMED arms print 452.25 MiB (MTP depth 2 bills three
seqs per slot, so 9 cells) — so pool arithmetic alone under-prices the shape.
**The shape (2026-10-18):** `--ctx-size 786432 --parallel 3` = THREE **The shape (2026-10-18):** `--ctx-size 786432 --parallel 3` = THREE
conversations of 262,144, every one at the model's TRAINED window (Frank: "if conversations of 262,144, every one at the model's TRAINED window (Frank: "if
@@ -1168,6 +1199,26 @@ linked runbooks.
is broken". Diagnose with `lastError` from `/api/v1/targets`, not the alert list, is broken". Diagnose with `lastError` from `/api/v1/targets`, not the alert list,
and note Alertmanager has no persistence: a reboot re-stamps `startsAt` on an and note Alertmanager has no persistence: a reboot re-stamps `startsAt` on an
alert that has been firing for weeks. alert that has been firing for weeks.
- **A leftover bench drop-in silently hijacks the next deploy, and no health
gate sees it.** The sanctioned bench-window method — a transient
`Environment=FRANK_<SVC>_ENV_FILE=/tmp/<arm>.env` drop-in, the arm env
`source`ing the repo env and then overriding it — outlives the bench unless
it is retired. On 2026-10-18 a leftover `frank-zealot.service.d/ptq1.conf` —
the arm Frank had ordered at 02:36 and measured from 03:14 — made the cutover
deploy boot a **different model at a different shape** (3 slots of 133,888,
not 3 × 262,144) while `/health` answered 200, `/v1/models` echoed the alias
(a flag, not a fact) and the Playwright gate passed 33/33. After deploying any
recently-benched LLM service: `systemctl show <svc> -p DropInPaths` (must be
empty), then `tr '\0' ' ' </proc/$(pidof llama-server)/cmdline`, then the boot
ledger — and retire drop-ins to `~/.AGENTTRASH/<date>-<topic>/` +
`daemon-reload`.
- **A bench verdict that never reaches the repo is indistinguishable from a
rogue arm.** Same window: PTQ1_0 served production for 24 minutes with real
numbers (~48 t/s decode, 801 t/s prefill at 141k), but the figures lived only
in `/tmp` env files and journald, so the next agent on the host read the
surviving drop-in as an unfinished experiment and reported a hijack. Write the
measurement into the runbook and the arm script's header in the session that
takes it — `/tmp` and the journal are not records.
**Ops** **Ops**
+43
View File
@@ -0,0 +1,43 @@
# frank-tts — the cloned-voice speech façade (dankclaw/voiceservice/app.py).
#
# Standard library only, so there is nothing to pip-install and nothing to
# compile: the image is a python runtime plus ffmpeg (the one binary the app
# shells out to, for response_format=mp3) plus one COPY of app.py. Rebuild to
# ship a new app.py.
#
# Build on dankclaw (deploy-remote.sh build_compose_image frank-tts does this,
# including --format docker, which the fleet's other local builds also pass):
# podman build --format docker -t franksdank/frank-tts:latest \
# -f dankclaw/voiceservice/Containerfile .
#
# BASE PINNED BY DIGEST, like every other image this fleet runs from a
# registry: a tag moves under a rebuild and a voice endpoint that changes
# python or ffmpeg mid-week is a debug session nobody asked for. The digest is
# the multi-arch INDEX of python:3.12-alpine as published 2026-10-18 (amd64 +
# arm64 on linux); Dankclaw resolves the amd64 entry.
#
# WHY 3.12: the fleet's ceiling for this app is python <= 3.12 (single file,
# no build step); the alpine flavour also keeps the image in the tens of MB
# and ships busybox `wget`, which is what the quadlet unit's HealthCmd runs —
# so no HEALTHCHECK belongs here (the probe lives in the unit, as in
# alertmanager/prometheus; only distroless bases need it baked in).
FROM docker.io/library/python:3.12-alpine@sha256:1b668429b3511ab407d8e00648891631b0b1a4d7e15e3ca70f38ab5b91ad4ab4
# ffmpeg: mp3 output. The app always asks the upstream for wav and re-encodes
# on the way out, so ffmpeg is the only external program in the picture.
RUN apk add --no-cache ffmpeg
# Unbuffered stdout: the unit's journal must show the "listening on" line (and
# any backend error) the moment it happens, not when the buffer fills.
ENV PYTHONUNBUFFERED=1
COPY dankclaw/voiceservice/app.py /app/app.py
# Non-root. The voice packs are bind-mounted read-only, so this user only ever
# reads them — they must be world-readable (the pack builder writes 644/755).
# The one thing it writes is its per-request temp dir under /tmp.
RUN adduser -D -u 10000 -h /app -s /sbin/nologin shim
USER shim
EXPOSE 7772
CMD ["python3", "/app/app.py"]
+413
View File
@@ -0,0 +1,413 @@
#!/usr/bin/env python3
"""frank-tts — the fleet's OpenAI-compatible speech façade (component: the shim).
One local endpoint that answers ``voice=<name>`` with audio in a cloned voice.
Consumers (the omp harness, OpenWebUI, open-notebook) speak the OpenAI
``POST /v1/audio/speech`` shape and never learn a file path: a voice is a
directory under ``$VOICES_DIR`` and ``reference.wav`` inside it is what makes
the name real. A name that has no reference is not a voice — the shim answers
400 with the list that is, instead of silently narrating in a fallback voice
(silent fallback is how a wrong-voice endpoint stays wrong for weeks).
Two backends sit behind the one surface, chosen by ``TTS_BACKEND``:
``omni-http``
Forward to an OpenAI-shaped upstream TTS server (``$TTS_UPSTREAM``) and
carry the cloned voice with the request as ``reference_audio`` (base64 of
``reference.wav``). The upstream does the inference; this process only
resolves the voice and, when asked, re-encodes to mp3 with ``ffmpeg``.
``llama-tts-cli``
Run a local binary per request (``$LLAMA_TTS_BIN``), hand it the
reference with ``--tts-speaker-file``, read the WAV it writes. ``nice -n
19`` because this box also serves text and a voice request must never
starve it.
Everything comes from the environment, read at REQUEST time (no
import-time-only globals): the same module can be imported with an injected
``Config`` or run with ``python3 app.py`` and the unit's env file.
Runtime law: single process, standard library only, no build step. Audio
bytes never reach the log — request lines and error text only.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
WAV_CONTENT_TYPE = "audio/wav"
MP3_CONTENT_TYPE = "audio/mpeg"
# A voice name is a plain directory name. Slashes and `..` are refused here so
# a client-supplied name can never escape $VOICES_DIR.
VOICE_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
class ShimError(Exception):
"""A failure with the HTTP status the client should see."""
def __init__(self, status, message):
super().__init__(message)
self.status = status
self.message = message
class Config:
"""Every knob of the shim. Constructed from the environment per request by
default, or injected into ``create_server`` for tests and embedding."""
def __init__(self, voices_dir, backend, upstream, upstream_model, llama_bin,
llama_model, llama_mmproj, llama_lang, llama_threads, port,
max_chars, timeout):
self.voices_dir = Path(voices_dir)
self.backend = backend
self.upstream = upstream
self.upstream_model = upstream_model
self.llama_bin = llama_bin
self.llama_model = llama_model
self.llama_mmproj = llama_mmproj
self.llama_lang = llama_lang
self.llama_threads = llama_threads
self.port = port
self.max_chars = max_chars
self.timeout = timeout
@classmethod
def from_env(cls, environ=None):
env = os.environ if environ is None else environ
return cls(
voices_dir=env.get("VOICES_DIR", "/voices"),
backend=(env.get("TTS_BACKEND") or "omni-http").strip().lower(),
upstream=(env.get("TTS_UPSTREAM") or "http://host.containers.internal:8880").rstrip("/"),
upstream_model=env.get("TTS_UPSTREAM_MODEL") or "voxcpm2",
llama_bin=env.get("LLAMA_TTS_BIN") or "",
llama_model=env.get("LLAMA_TTS_MODEL") or "",
llama_mmproj=env.get("LLAMA_TTS_MMPROJ") or "",
llama_lang=env.get("LLAMA_TTS_LANG") or "english",
llama_threads=_int(env, "LLAMA_TTS_THREADS", 8),
port=_int(env, "SHIM_PORT", 7772),
max_chars=_int(env, "SHIM_MAX_CHARS", 15000),
timeout=float(_int(env, "SHIM_TIMEOUT", 600)),
)
def _int(env, name, default):
try:
return int(str(env.get(name, "")).strip())
except (TypeError, ValueError):
return default
def list_voices(voices_dir):
"""Voice names in ``voices_dir``: directories that hold a ``reference.wav``.
A missing or unreadable directory is an empty fleet, not an error — the
health endpoint must still answer so the unit reports its state.
"""
try:
entries = sorted(p.name for p in voices_dir.iterdir()
if p.is_dir() and (p / "reference.wav").is_file())
except OSError:
return []
return entries
def resolve_voice(voices_dir, voice):
"""``$VOICES_DIR/<voice>/reference.wav`` for a valid name, else ``None``."""
if not isinstance(voice, str) or not VOICE_NAME_RE.fullmatch(voice):
return None
reference = voices_dir / voice / "reference.wav"
return reference if reference.is_file() else None
def to_mp3(wav_bytes, timeout):
"""Re-encode WAV bytes to mp3 with the image's ``ffmpeg`` (pipes, no temp
file: the audio never lands on disk to be left behind)."""
argv = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "pipe:0",
"-f", "mp3", "pipe:1"]
try:
proc = subprocess.run(argv, input=wav_bytes, capture_output=True,
timeout=timeout)
except FileNotFoundError:
raise ShimError(500, "response_format=mp3 needs the ffmpeg binary, which is not on PATH")
except subprocess.TimeoutExpired:
raise ShimError(502, "ffmpeg mp3 conversion timed out after %gs" % timeout)
if proc.returncode != 0 or not proc.stdout:
raise ShimError(502, "ffmpeg mp3 conversion failed (exit %s): %s"
% (proc.returncode, _tail(proc.stderr)))
return proc.stdout
def _tail(raw):
"""Last line of a subprocess' stderr, for an error message. Text only —
audio bytes are never echoed, and never logged."""
text = (raw or b"").decode("utf-8", "replace").strip()
return text.splitlines()[-1][:300] if text else "no output"
def _error(message, voices=None):
body = {"error": {"message": message, "type": "invalid_request_error"}}
if voices is not None:
body["error"]["available_voices"] = voices
return body
class SpeechHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "frank-tts"
sys_version = ""
# -- plumbing ----------------------------------------------------------
def config(self):
return getattr(self.server, "config", None) or Config.from_env()
def log_message(self, fmt, *args):
# One prefix for the journal. Request lines and outcomes only: a
# synthesized text, a base64 reference voice and the audio bytes
# itself never reach stderr, in success or in failure.
sys.stderr.write("frank-tts: %s - %s\n"
% (self.address_string(), fmt % args))
def log_error(self, fmt, *args):
# The base class routes refused requests through a separate sink; keep
# them on the same line format so the journal reads uniformly.
self.log_message(fmt, *args)
def _json(self, status, obj, head=False):
self._bytes(status, json.dumps(obj).encode("utf-8"), "application/json", head)
def _bytes(self, status, data, content_type, head=False):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if not head:
self.wfile.write(data)
def _drain(self):
"""Read and discard a body this route will not answer. HTTP/1.1
keep-alive makes an unread body the NEXT request line, so a client
that reuses the connection would see a parse error it never sent."""
if getattr(self, "_body_read", False):
return # already consumed; a second read would block the response
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
return
if length > 0:
self.rfile.read(length)
def _body(self):
"""Parse the JSON request body. Never returned to a log or an error."""
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
raise ValueError("Content-Length is not a number")
raw = self.rfile.read(length) if length > 0 else b""
self._body_read = True
if not raw:
raise ValueError("expected a JSON body")
parsed = json.loads(raw.decode("utf-8"))
if not isinstance(parsed, dict):
raise ValueError("expected a JSON object")
return parsed
# -- routes ------------------------------------------------------------
def do_GET(self, head=False):
# HEAD shares the route table: `wget --spider` (the fleet's probe
# shape) asks for HEAD, and a 501 there would read as a dead service.
path = urllib.parse.urlparse(self.path).path
cfg = self.config()
if path == "/health":
self._json(200, {"status": "ok", "backend": cfg.backend,
"voices": list_voices(cfg.voices_dir)}, head)
elif path == "/v1/audio/voices":
self._json(200, list_voices(cfg.voices_dir), head)
else:
self._json(404, _error("no such endpoint: %s" % path), head)
def do_HEAD(self):
self.do_GET(head=True)
def do_POST(self):
path = urllib.parse.urlparse(self.path).path
if path != "/v1/audio/speech":
self._drain()
self._json(404, _error("no such endpoint: %s" % path))
return
cfg = self.config()
try:
body = self._body()
except (ValueError, UnicodeDecodeError) as exc:
self._drain()
self._json(400, _error("malformed request body: %s" % exc))
return
text = body.get("input")
if not isinstance(text, str) or not text.strip():
self._json(400, _error("`input` must be a non-empty string"))
return
if len(text) > cfg.max_chars:
self._json(414, _error("input is %d characters, over the %d limit "
"(SHIM_MAX_CHARS); split it or raise the limit"
% (len(text), cfg.max_chars)))
return
fmt = str(body.get("response_format") or "wav").strip().lower()
if fmt not in ("wav", "mp3"):
self._json(400, _error("unsupported response_format %r; this "
"endpoint serves wav and mp3" % fmt))
return
# `voice_id` is the alias the omp harness forwards for `voice`.
voice = body.get("voice") or body.get("voice_id")
reference = resolve_voice(cfg.voices_dir, voice)
if reference is None:
available = list_voices(cfg.voices_dir)
listed = ", ".join(available) if available else "(none: no voice pack with a reference.wav in %s)" % cfg.voices_dir
self._json(400, _error("unknown voice %r; available voices: %s"
% (voice, listed), available))
return
model = str(body.get("model") or cfg.upstream_model)
try:
if cfg.backend == "omni-http":
audio = self._speech_http(cfg, model, text, str(voice), reference)
elif cfg.backend == "llama-tts-cli":
audio = self._speech_cli(cfg, text, reference)
else:
raise ShimError(500, "unknown TTS_BACKEND %r (expected omni-http or llama-tts-cli)" % cfg.backend)
if fmt == "mp3":
audio = to_mp3(audio, cfg.timeout)
except ShimError as exc:
self._json(exc.status, _error(exc.message))
return
content_type = MP3_CONTENT_TYPE if fmt == "mp3" else WAV_CONTENT_TYPE
self._bytes(200, audio, content_type)
self.log_message("speech voice=%s format=%s chars=%d -> %d bytes %s",
voice, fmt, len(text), len(audio), content_type)
# -- backends ----------------------------------------------------------
def _speech_http(self, cfg, model, text, voice, reference):
"""Forward to the upstream with the cloned voice riding along as
base64 ``reference_audio``. The upstream is asked for wav always; mp3
is our own re-encode on the way out."""
try:
payload = {
"model": model,
"input": text,
"voice": voice,
"response_format": "wav",
"reference_audio": base64.b64encode(reference.read_bytes()).decode("ascii"),
}
except OSError as exc:
raise ShimError(500, "cannot read %s: %s" % (reference, exc))
url = cfg.upstream + "/v1/audio/speech"
request = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(request, timeout=cfg.timeout) as response:
audio = response.read()
except urllib.error.HTTPError as exc:
detail = exc.read(512).decode("utf-8", "replace").strip()
raise ShimError(502, "upstream %s returned HTTP %d: %s"
% (url, exc.code, detail or "no detail"))
except urllib.error.URLError as exc:
raise ShimError(502, "upstream %s unreachable: %s" % (url, exc.reason))
except (TimeoutError, OSError) as exc:
raise ShimError(502, "upstream %s failed: %s" % (url, exc))
if not audio:
raise ShimError(502, "upstream %s returned no audio" % url)
return audio
def _speech_cli(self, cfg, text, reference):
"""Run the local TTS binary once, in a temp dir that is always removed
— also on failure, or a crash-looping binary fills /tmp."""
if not cfg.llama_bin or not cfg.llama_model:
raise ShimError(500, "TTS_BACKEND=llama-tts-cli needs LLAMA_TTS_BIN and LLAMA_TTS_MODEL")
work = tempfile.TemporaryDirectory(prefix="frank-tts-")
try:
out_path = Path(work.name) / "speech.wav"
argv = ["nice", "-n", "19", "env", "OMP_NUM_THREADS=%d" % cfg.llama_threads,
cfg.llama_bin, "-m", cfg.llama_model]
if cfg.llama_mmproj:
argv += ["-mm", cfg.llama_mmproj]
argv += ["--tts-lang", cfg.llama_lang,
"--tts-speaker-file", str(reference),
"-p", text,
"--output", str(out_path)]
try:
proc = subprocess.run(argv, cwd=work.name, capture_output=True,
timeout=cfg.timeout)
except FileNotFoundError as exc:
raise ShimError(500, "cannot run the TTS command: %s" % exc)
except subprocess.TimeoutExpired:
raise ShimError(502, "TTS binary timed out after %gs" % cfg.timeout)
if proc.returncode != 0:
raise ShimError(502, "TTS binary failed (exit %s): %s"
% (proc.returncode, _tail(proc.stderr)))
if not out_path.is_file():
raise ShimError(502, "TTS binary exited 0 but wrote no %s" % out_path.name)
return out_path.read_bytes()
finally:
work.cleanup()
class SpeechServer(ThreadingHTTPServer):
"""Threads so a health probe answers while a long synthesis runs; daemon
threads so SIGTERM is never held hostage by a stuck request."""
daemon_threads = True
allow_reuse_address = True
config = None
def create_server(config=None, host="0.0.0.0", port=None):
"""Bind the shim. ``config``/``port`` are the injectable seams: tests pass
a Config with a temp VOICES_DIR and ``port=0`` for an ephemeral port."""
cfg = Config.from_env() if config is None else config
server = SpeechServer((host, cfg.port if port is None else port), SpeechHandler)
server.config = cfg
return server
def main(argv=None):
parser = argparse.ArgumentParser(
prog="frank-tts", description="OpenAI-compatible speech endpoint for cloned voice packs")
parser.add_argument("--host", default="0.0.0.0",
help="bind address (default: all interfaces)")
parser.add_argument("--port", type=int, default=None,
help="bind port (default: $SHIM_PORT, 0 = ephemeral)")
args = parser.parse_args(argv)
server = create_server(host=args.host, port=args.port)
cfg = server.config
bound = server.server_address[1]
print("frank-tts: backend=%s voices_dir=%s listening on http://%s:%d"
% (cfg.backend, cfg.voices_dir, args.host, bound), flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,71 @@
# Quadlet unit: frank-tts (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/frank-tts.container
# Unit name: frank-tts.service (container name: frank-tts)
#
# WHAT IT IS. The fleet's one OpenAI-compatible speech endpoint: consumers
# (omp, OpenWebUI, open-notebook) POST /v1/audio/speech with voice=<name> and
# get audio in that cloned voice. A voice is a directory under
# /home/dank/voices holding reference.wav; the shim refuses any name without
# one (400 + the list that does exist) rather than narrating in a fallback
# voice nobody chose. The engine behind it is a separate decision —
# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to an
# OpenAI-shaped upstream on host :8880 and running a local CLI binary per
# request.
#
# IMAGE. Built locally from dankclaw/voiceservice/Containerfile by
# deploy-remote.sh (build_compose_image), like loki/alloy: python 3.12-alpine
# pinned by digest + ffmpeg, app.py copied in, running as uid 10000.
# Pull=never and the localhost/ prefix are required — this host's quadlet
# path cannot resolve a locally built tag from local storage otherwise.
#
# NETWORK. Bridge (dankclaw_default), NOT host: nothing here needs a shared
# loopback namespace, and the comfyui/loki/authentik units are bridge too.
# Consequences both ways: consumers reach it on the published 7772, and the
# shim reaches the host-side TTS engine through host.containers.internal
# (never the LAN IP — a bridge netns cannot route to the host's own address
# for every bind shape, and the fleet's law is the gateway name). NOTE for
# whoever starts the :8880 engine: host.containers.internal reaches the host
# network namespace, so the engine must bind an address that namespace
# accepts; a 127.0.0.1-only bind is not it (the 2026-09-17 signal-relay hop
# needed the host LAN bind for the same reason).
#
# VOICES. /home/dank/voices is host state, never repo state, and is mounted
# READ-ONLY: this endpoint has no business writing voice packs. Because the
# container runs as uid 10000, the packs must be world-readable (644 files,
# 755 directories), which is how the pack builder writes them.
#
# NO GPU. The shim is a façade: audio conversion via ffmpeg on the CPU and,
# for the CLI backend, whatever CPU threads LLAMA_TTS_THREADS allows, at nice
# 19. No /dev/kfd, no /dev/dri, no keep-groups — unlike comfyui, whose unit
# carries them deliberately.
[Unit]
Description=frank-tts (cloned-voice OpenAI-compatible TTS facade)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=frank-tts
Image=localhost/franksdank/frank-tts:latest
Pull=never
Network=dankclaw_default
PublishPort=7772:7772
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env
Volume=/home/dank/voices:/voices:ro
# The probe is a probe, not a voice test: /health answers 200 with the backend
# and the resolved voice list, so a mounted-but-empty /voices still reads
# healthy while a dead app does not. busybox wget ships in the alpine base;
# keep the command argument-only — the podman 5.x generator splits HealthCmd
# on spaces (see the comfyui unit for the quoting trap).
HealthCmd=wget --spider -q http://127.0.0.1:7772/health
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=30s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -23,6 +23,15 @@
# moving 17 % less weight per step can pay. That is an argument, not a # moving 17 % less weight per step can pay. That is an argument, not a
# measurement: this script exists so the decision is made on OUR card, against # measurement: this script exists so the decision is made on OUR card, against
# the PQ2_0 record (76.6 t/s single-stream decode, 1,258 t/s prefill at 35k). # the PQ2_0 record (76.6 t/s single-stream decode, 1,258 t/s prefill at 35k).
# MEASURED ON OUR CARD (2026-10-18). Frank ordered this shape at 02:36 — Q8
# mmproj, batch 3072/ubatch 1536, two contexts — and the unit ran it as
# production 03:14:39→03:38:57 (pid 191358, journal `type ptq1_0: 402 tensors`).
# Its own print_timing lines: decode **48.0 / 47.6 t/s at 512 tokens** (47.9 at
# 229, 49.0 at 451), 25–27 t/s per stream with two streams live, prefill 1,295
# t/s at 30,637 → 943 at 97,856 → 801 at 141,365. The 1,201 MiB arrived and the
# decode tax came with it: −37 % against the PQ2_0 record, so the Ampere warning
# DOES generalize to GA102. Frank scratched the 27B plan at 03:33 for the 9B;
# this payload is the 27B rollback, not a candidate.
# #
# THE RUNTIME LOCK CARRIES OVER. Both packings are custom GGML tensor types and # THE RUNTIME LOCK CARRIES OVER. Both packings are custom GGML tensor types and
# stock llama.cpp rejects them ("It rejects PQ2_0 and PTQ1_0 as unknown types" # stock llama.cpp rejects them ("It rejects PQ2_0 and PTQ1_0 as unknown types"
+15 -12
View File
@@ -68,10 +68,11 @@
# ceiling does NOT move with it: HALOGEN_CTX stays 200,704, because the # ceiling does NOT move with it: HALOGEN_CTX stays 200,704, because the
# checkpoint's native window is 262,144 and the engine refuses to go past it # checkpoint's native window is 262,144 and the engine refuses to go past it
# without HALOGEN_ROPE_YARN (its own 400 text says so). ~28 KiB/position # without HALOGEN_ROPE_YARN (its own 400 text says so). ~28 KiB/position
# (FLAGS.md) ⇒ ~10.8 GiB, where pool == ctx (200,704) cost 5.5, 262,144 cost # (FLAGS.md) ⇒ the DEPLOYED 2026-10-18 boot RESERVES 11.0 GiB (`reserving the
# 7.2 and the 2026-10-06 shape's 524,288 measured 14.6. The engine's preflight # KV pool, 401408 positions (about 11.0 GiB)`), where pool == ctx (200,704)
# formula bills a little more than the reservation (block scratch + ~115 # cost 5.5, 262,144 cost 7.2 and the 2026-10-06 shape's 524,288 measured 14.6.
# MiB/slot of O(1) state) — quote the RESERVATION. FLAGS.md law: "RAM the pool # The preflight formula bills 11.3 (block scratch + ~115 MiB/slot of O(1)
# state) — quote the RESERVATION. FLAGS.md law: "RAM the pool
# takes is RAM that table loses" — the 47.68 GiB n-gram table surrenders this # takes is RAM that table loses" — the 47.68 GiB n-gram table surrenders this
# much page cache, so the warm prompt-cache ratio is what to re-measure. # much page cache, so the warm prompt-cache ratio is what to re-measure.
# HALOGEN_KV_SLOTS=2 restores the w4b-era shape on this box: the MTP # HALOGEN_KV_SLOTS=2 restores the w4b-era shape on this box: the MTP
@@ -82,14 +83,16 @@
# Memory ledger against the box's 121.2 GiB unified (the n-gram table is # Memory ledger against the box's 121.2 GiB unified (the n-gram table is
# mmap'd page cache beside the checkpoint, not a pinned charge — the boot # mmap'd page cache beside the checkpoint, not a pinned charge — the boot
# ledger agrees: "mapped 51.2 GB, NOT pinned (Pin::None)"): # ledger agrees: "mapped 51.2 GB, NOT pinned (Pin::None)"):
# weights ~62.9 GiB locked (0.8 of them the vision tower) + KV ~10.8 + # weights 62.9 GiB locked (0.8 of them the vision tower) + KV 11.0 +
# working ~5.7 (HALOGEN_MAX_TOK=8192) ≈ 79.4 GiB engine-side, with the # working 5.7 (HALOGEN_MAX_TOK=8192) = 79.6 GiB engine-side (the boot
# prompt cache (~1.0 at this ctx; 1.3 was measured at 262144) booked # ledger of the 401408-position deploy, 2026-10-18), with the 1.3 GiB
# separately by the ledger; HALOGEN_HOST_RESERVE_GIB=20 keeps the host # prompt cache (12 entries x 111 MiB at ctx 200704) booked separately by
# honest. Against the 121.2 GiB box that leaves ~22 GiB over the reserve for # the ledger; HALOGEN_HOST_RESERVE_GIB=20 keeps the host honest. Against
# baseline co-tenancy (frank-zealot, ComfyUI, Caddy/observ ~10-15). The boot # the 121.2 GiB box that leaves ~20 GiB over the reserve (MemAvailable
# ledger is the referee — read it after deploy and re-size if the engine # read 22 GiB right after the raise) for baseline co-tenancy
# prints a clamp. # (frank-zealot, ComfyUI, Caddy/observ ~10-15). The boot ledger is the
# referee — read it after deploy and re-size if the engine prints a
# clamp.
# HALOGEN_SPEC_ADAPT=0 is MANDATORY (card): the default adaptive policy # HALOGEN_SPEC_ADAPT=0 is MANDATORY (card): the default adaptive policy
# turns the MTP head off for whole requests after low acceptance and # turns the MTP head off for whole requests after low acceptance and
# HALVES decode as a result. Frank accepted the head always-on trade # HALVES decode as a result. Frank accepted the head always-on trade
+71
View File
@@ -0,0 +1,71 @@
# frank-tts.env — tunables for **frank-tts**, the fleet's cloned-voice speech
# endpoint on Dankclaw, host port **7772** (the LLM port family: 7770
# frank-dragoon, 7771 frank-zealot, 7772 frank-tts). Read by the Quadlet unit
# (EnvironmentFile=), which is why the paths below are CONTAINER paths.
#
# WHAT THIS IS. One OpenAI-compatible /v1/audio/speech façade in front of
# whichever TTS engine wins the engine decision (dankclaw/voiceservice/app.py
# is a single stdlib-only file in a locally built image). Consumers — omp's
# TTS device, OpenWebUI, open-notebook — address a VOICE BY NAME
# (voice=narrator1, or the harness' voice_id=narrator1) and never see a path:
# a name is real exactly when /voices/<name>/reference.wav exists, and
# anything else is a 400 that lists the names that are.
#
# THE PACKS. /home/dank/voices on the host, mounted read-only at /voices. One
# directory per voice, and a directory is a PACK only when it carries BOTH
# required members:
# reference.wav 24 kHz mono PCM, 18-25 s of clean single-speaker audio,
# plus the 16 kHz variant some engines want;
# the engine artifact the pinned backend's own speaker file (embedding /
# speaker weights). REQUIRED, beside the WAV and never
# replacing it — a voice that cannot be reused by another
# runtime is not a saved voice.
# plus manifest.json for provenance: source file, offsets (always past the
# first 10 s of that file, the source-material law), engine, sha256. Packs are
# host state and stay out of this repo; they must be world-readable because the
# container runs as uid 10000. Who enforces what: the shim gates on
# reference.wav because that is the only engine-agnostic term, so the pack
# builder is what refuses to emit a pack without its engine artifact — until a
# winning engine pins the format and this gate can name the file.
#
# BACKEND SWITCH. TTS_BACKEND picks the shape and nothing else changes for the
# consumer:
# omni-http forward to an OpenAI-shaped upstream on the host (:8880),
# carrying the voice along as base64 reference_audio. The
# engine does the inference; the shim resolves the voice and
# re-encodes to mp3 when asked.
# llama-tts-cli run LLAMA_TTS_BIN once per request with
# --tts-speaker-file <reference.wav>, nice 19 so a narration
# never starves the text endpoints on this box. Needs
# LLAMA_TTS_BIN + LLAMA_TTS_MODEL filled in; LLAMA_TTS_MMPROJ
# only if that binary takes a projector.
# The upstream address is host.containers.internal — the gateway name a bridge
# container uses for the host. It is NOT the LAN IP and it is NOT
# 127.0.0.1: a bridge netns has neither.
VOICES_DIR=/voices
TTS_BACKEND=omni-http
TTS_UPSTREAM=http://host.containers.internal:8880
TTS_UPSTREAM_MODEL=voxcpm2
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
# configured": the endpoint answers a 500 that says so instead of running
# `nice env -m ...` with an empty binary name.
LLAMA_TTS_BIN=
LLAMA_TTS_MODEL=
LLAMA_TTS_MMPROJ=
LLAMA_TTS_LANG=english
LLAMA_TTS_THREADS=8
# The wire shape. 7772 is frank-tts's host port (the container's too), the next
# slot in the fleet's model-endpoint family; the TTS engine upstream of it
# keeps 8880, which is not a fleet endpoint.
SHIM_PORT=7772
# Longest single narration, in characters of `input`. Podcast-length text is
# the consumer's job to chunk; one request over this limit is refused with 414
# rather than silently truncated into a half-sentence of audio.
SHIM_MAX_CHARS=15000
# Seconds one request may take, covering both an upstream synthesis and a CLI
# run. Voice is slow compared to text — a long paragraph on a CPU engine is
# minutes, not milliseconds — and a client that gave up earlier would only
# retry into a second synthesis competing for the same CPU.
SHIM_TIMEOUT=600
+20 -13
View File
@@ -65,9 +65,11 @@ UBATCH_SIZE=2048
# the ledger said exactly that. HALF the 27B's 34,816 B/token, which is the # the ledger said exactly that. HALF the 27B's 34,816 B/token, which is the
# whole reason three native windows fit. f16 would bill 26,112 MiB at this pool # whole reason three native windows fit. f16 would bill 26,112 MiB at this pool
# (against a 24,123 MiB card) and dies at cudaMalloc; the 24 DeltaNet layers # (against a 24,123 MiB card) and dies at cudaMalloc; the 24 DeltaNet layers
# bill a fixed recurrent state per SLOT instead of per token — the ledger's # bill a fixed recurrent state per SLOT instead of per token — the PRODUCTION
# "RS buffer size = 452.25 MiB (3 cells, 32 layers, 3 seqs)" = 150.75 MiB a # ledger prints "RS buffer size = 150.75 MiB (3 cells, 32 layers, 3 seqs)"
# slot — which is why pool arithmetic alone under-prices the shape. # (~50 MiB a cell), while the SPEC-ARMED arms print 452.25 MiB, because MTP
# depth 2 bills three seqs per slot (9 cells) — which is why pool arithmetic
# alone under-prices the shape.
CACHE_TYPE_K=q8_0 CACHE_TYPE_K=q8_0
CACHE_TYPE_V=q8_0 CACHE_TYPE_V=q8_0
# SPECULATIVE DECODING: OFF, MEASURED. The head is in the file and the drafter # SPECULATIVE DECODING: OFF, MEASURED. The head is in the file and the drafter
@@ -103,18 +105,23 @@ SPEC_DRAFT_P_MIN=0
# PRESENCE_PENALTY="" is the documented opt-out. # PRESENCE_PENALTY="" is the documented opt-out.
PRESENCE_PENALTY=1.0 PRESENCE_PENALTY=1.0
NGL=99 NGL=99
# Boot ledger (2026-10-18, this exact shape, nvidia-smi settled): # Boot ledger (2026-10-18 PRODUCTION boot under the unit, verbosity 4):
# 17,876 MiB used / 6,249 MiB free of 24,123 # 17,922 MiB used / 6,203 MiB free of 24,123 (nvidia-smi settled)
# CUDA0 model 2,036.71 MiB + CPU_Mapped 257.66 MiB
# CUDA0 KV 13,056.00 MiB (q8_0 K + q8_0 V, 786,432 positions) # CUDA0 KV 13,056.00 MiB (q8_0 K + q8_0 V, 786,432 positions)
# CUDA0 RS 452.25 MiB (3 recurrent-state cells) # CUDA0 RS 150.75 MiB (3 cells, 32 layers, 3 seqs)
# compute 1,409 MiB CUDA + 1,201 MiB CUDA_Host, projector 595 + 248 MiB # compute 1,409.02 MiB CUDA + 1,088.87 MiB CUDA_Host (+2.84 MiB output buf)
# mmproj worst-case estimate 868.32 MiB (248.10 CUDA + 24.93 CPU compute
# meta) — charged AFTER the engine's own fit check, as always
# no clamp line, no cudaMalloc error # no clamp line, no cudaMalloc error
# Measured on the same boot: 167.2 t/s greedy decode (/completion, 512 tokens), # Measured on this boot (/completion timings, greedy, ignore_eos, 512 tokens):
# 158.9 t/s single-stream chat, 280.2 t/s aggregate across THREE concurrent # 168.7 t/s decode (three runs 168.4-168.7) and ~300 t/s aggregate across
# streams (93.8 each), 2,877 t/s prefill at 106,418 tokens. The 27B record it # THREE concurrent streams (102.8 each). The trial arm's record (167.2 decode,
# replaces: 76.6 decode, 109.4 aggregate across two streams, 691.7 prefill at # 280.2 aggregate, 2,877 t/s prefill at 106,418) stands; the 27B it replaces
# 166k — beaten on every axis. The FIRST request after a boot is a warmup # gave 76.6 / 109.4 / 691.7 at 166k. The FIRST request after a boot is a
# artifact (37.7 t/s class); never report it. # warmup artifact (37.7 t/s class) and a natural generation that stops early
# makes a wall-clock aggregate read 4x low - measure with ignore_eos and read
# the engine's own timings.
# Fallback rung if the margin is ever eaten (desktop, co-tenant): q4_0 KV on the # Fallback rung if the margin is ever eaten (desktop, co-tenant): q4_0 KV on the
# 27B era measured 16,392 used / 7,733 free at 2 × 200,704; the same drop here # 27B era measured 16,392 used / 7,733 free at 2 × 200,704; the same drop here
# frees ~6.5 GiB at three windows. UBATCH_SIZE=1024 is the cheaper lever # frees ~6.5 GiB at three windows. UBATCH_SIZE=1024 is the cheaper lever
+12 -5
View File
@@ -53,11 +53,18 @@ Policy while candidate arms held the port): the candidate — Ternary-Bonsai-2-*
PQ2_0 with the MTP head, 3 × 262,144 — was **deployed as production**, so the PQ2_0 with the MTP head, 3 × 262,144 — was **deployed as production**, so the
picker row below was re-keyed to it (see the next paragraph). picker row below was re-keyed to it (see the next paragraph).
The `model` table carries exactly one decoration row, `frank-zealot` The `model` table carries exactly two decoration rows, `frank-zealot` and
(context length 262,144 = the per-slot window since 2026-10-18; description `frank-dragoon`. Both the context length and the description live **inside
re-keyed the same day to Ternary-Bonsai-2-9B PQ2_0 + MTP + Q8_0 vision mmproj — JSON columns** — `params` holds `{"context": {"length": …}}` and `meta` holds
the row's `description` is live DB state, so a payload swap must re-key it in `{"description": …}`; there is no `context_length` or `description` column, so
the database too, not only in the repo). `sqlite3` one-liners against those names fail. Re-keyed 2026-10-18:
`frank-zealot` → 262,144 with the Bonsai-2-9B description, and `frank-dragoon`
→ 200,704 (its `params` had kept the pre-lowering 262,144 window from
2026-10-17 while its own description already said 200,704). These rows are
live DB state, so a payload swap must re-key the database too, not only the
repo. There is no `sqlite3` binary on Dankclaw — use `python3 -c` with the
stdlib `sqlite3` module, and take a `Connection.backup()` snapshot first (the
DB is in WAL and the app is live).
A third gate sits *after* the table, and it is the one that bites family A third gate sits *after* the table, and it is the one that bites family
accounts (2026-10-07): a model row is only visible to a non-admin user when accounts (2026-10-07): a model row is only visible to a non-admin user when
@@ -202,10 +202,9 @@ boot ledger is unchanged to the MiB — 62.9 GiB weights (0.8 of them vision) +
GiB KV + 5.7 GiB working = 74.1 GiB, two slots over ONE 200,704-position pool, MTP GiB KV + 5.7 GiB working = 74.1 GiB, two slots over ONE 200,704-position pool, MTP
depth 2, prompt cache ON, no clamp line — and Gitea, the Caddy edge and Grafana all depth 2, prompt cache ON, no clamp line — and Gitea, the Caddy edge and Grafana all
answered 200. (Later the same day the pool DOUBLING to 401,408 positions for answered 200. (Later the same day the pool DOUBLING to 401,408 positions for
slot parity was COMMITTED — `2b12716` — but as of 2026-10-18 NOT yet slot parity was COMMITTED (`2b12716`) and **deployed the same evening**: the
redeployed: the live boot still reserves ONE 200,704-position pool (5.5 GiB), boot now reserves `401408 positions (about 11.0 GiB)` and the engine-side total
host `frank-dragoon.env` included. When it lands, the reservation is ~10.8 is `79.6 GiB in all` (62.9 weights + 11.0 KV + 5.7 working),
GiB and the engine-side total ~79.4 GiB;
see [[frank-dragoon-flash-server-v2-native]].) see [[frank-dragoon-flash-server-v2-native]].)
**The NPU came back with the flip.** `amdxdna` bound to `0000:c6:00.1` (Strix/ **The NPU came back with the flip.** `amdxdna` bound to `0000:c6:00.1` (Strix/
@@ -94,9 +94,9 @@ the pool: **two long conversations can no longer share the endpoint** (two of
≤100k can). That is acceptable here because frank-dragoon carries the `slow` ≤100k can). That is acceptable here because frank-dragoon carries the `slow`
(medium-thinking) role and the `security-reviewer` subagent, not the (medium-thinking) role and the `security-reviewer` subagent, not the
interactive workhorse (that is frank-archon). interactive workhorse (that is frank-archon).
Frank lifted exactly that concurrency limit on 2026-10-18 — see *Pool Frank lifted exactly that concurrency limit on 2026-10-18 and the lift is
doubled for slot parity* below; that lift is COMMITTED but not yet deployed, **deployed** (section below): the pool is again `slots × ctx`, so two
so the sentences above remain the live truth until the next redeploy. maximum-length conversations fit the endpoint at once.
**Memory ledger** (121.2 GiB unified), from the boot ledgers. The 22:20 boot **Memory ledger** (121.2 GiB unified), from the boot ledgers. The 22:20 boot
printed `pinned 62.11 GiB in 1 range(s) in 1.6 s (41.0 GB/s)` trunk, printed `pinned 62.11 GiB in 1 range(s) in 1.6 s (41.0 GB/s)` trunk,
@@ -115,7 +115,7 @@ that long prompts read.
`HALOGEN_FLASH_PIN_TRUNK=0` is **rejected by the v2 loader** — the trunk `HALOGEN_FLASH_PIN_TRUNK=0` is **rejected by the v2 loader** — the trunk
stays pinned. Disk-backing the weights is not a lever on this format. stays pinned. Disk-backing the weights is not a lever on this format.
## Pool doubled for slot parity — committed 2026-10-18, awaiting redeploy ## Pool doubled for slot parity — committed AND deployed 2026-10-18
`HALOGEN_KV_POOL_POSITIONS` **200,704 → 401,408** (commit `2b12716`), Frank's `HALOGEN_KV_POOL_POSITIONS` **200,704 → 401,408** (commit `2b12716`), Frank's
call: frank-dragoon's total in-flight context was **half** frank-zealot's — call: frank-dragoon's total in-flight context was **half** frank-zealot's —
@@ -133,24 +133,34 @@ conversation had to stay under ~100k (commit message;
(`scripts/serve-frank-dragoon.sh:85-95`); 401,408 is `slots × ctx`, the top (`scripts/serve-frank-dragoon.sh:85-95`); 401,408 is `slots × ctx`, the top
of the legal band, and exactly frank-zealot's pool number — parity, not of the legal band, and exactly frank-zealot's pool number — parity, not
approximation. approximation.
- Expected reservation **~10.8 GiB** at ~28 KiB/position (preflight formula, - Measured reservation **11.0 GiB** — the boot ledger prints `reserving the KV
`scripts/frank-dragoon.env:70-72`); the measured points beside it are 5.5 pool, 401408 positions (about 11.0 GiB)`. The preflight KV-budget line bills
GiB at 200,704 (boot ledger 2026-10-18), 7.2 GiB reserved / 7.4 GiB billed **11.3 GiB** (`~28 KiB/position incl. block scratch + ~115 MiB/slot`) plus
at 262,144, and 14.6 GiB at the 2026-10-06 shape's 524,288 1.3 GiB of prompt cache. The measured points beside it: 5.5 GiB at 200,704,
7.2 GiB at 262,144, and 14.6 GiB at the 2026-10-06 shape's 524,288
(`scripts/frank-dragoon.env:49-52`, `:74-76`). (`scripts/frank-dragoon.env:49-52`, `:74-76`).
- That RAM comes out of the n-gram table's page cache — FLAGS.md law: RAM the - That RAM comes out of the n-gram table's page cache — FLAGS.md law: RAM the
pool takes is RAM that table loses. pool takes is RAM that table loses.
**Deploy state as of 2026-10-18: NOT yet deployed.** The live container **Deployed 2026-10-18** (`./scripts/deploy.sh dankclaw --service
(booted 2026-10-18 01:25) still runs `HALOGEN_KV_POOL_POSITIONS=200704` — frank-dragoon`, Playwright gate 33/33). The boot ledger confirms the shape:
verified in its `flash_serve` `/proc/<pid>/environ` and in the host-deployed `reserving the KV pool, 401408 positions (about 11.0 GiB)`,
`frank-dragoon.env` — and the 2026-10-18 boot ledger reserves `the KV pool, `2 slots over ONE 401408-position KV pool, each request up to 200704 (it
200704 positions (about 5.5 GiB)` with `74.1 GiB in all` reserves prompt + max_tokens)`, and the sum
(`/home/dank/logs/frank_dragoon.log`). Until the next `62.9 GiB of weights locked in RAM (0.8 of them the vision tower's), 11.0 GiB
`./scripts/deploy.sh dankclaw --service frank-dragoon`, the boot ledger keeps of KV pool, 5.7 GiB of working memory, 79.6 GiB in all`. Prompt cache ON (12
printing `2 slots over ONE 200704-position KV pool`; the first boot at the entries × 111 MiB at ctx 200704 = the 1.3 GiB in the KV-budget line), n-gram
new pool must show `ONE 401408-position KV pool` and the ~10.8 GiB 47.68 GiB still disk-backed (`Pin::None`), no clamp line. Host `MemAvailable`
reservation — read that line before quoting 401,408 as live anywhere. fell 26 → 22 GiB across the raise, which is the FLAGS.md collision being paid
in page cache. **Warm reuse survived it — measured after the deploy.** One
**101,987**-token wikitext prompt through `/v1/chat/completions` took **81.5 s**
cold (1,251 t/s of prefill) and **0.3 s** on each of two repeats:
`HALOGEN_CACHE_INPLACE=1` keeps the KV rows in the pool, so a warm turn prefills
nothing and the doubling evicted nothing the cache needed. (The pre-raise
136.7× figure was a `ttft` reading; this engine sends **no `Server-Timing`**
header and has **no `/completion` path** — `/ping`, `/props`, `/tokenize` are
404 too — so wall-clock around the POST plus `usage.prompt_tokens` is the only
instrument available here.)
## Measured A/B — same battery, both boots (2026-10-17) ## Measured A/B — same battery, both boots (2026-10-17)
@@ -231,7 +241,12 @@ is mid-thought); vision via OpenAI `image_url` content parts only, ≥256 px
probes; `response_format` unsupported; the chat template rejects probes; `response_format` unsupported; the chat template rejects
`reasoning_effort` outside `xhigh|medium|low` (the `models.yml` fold stays `reasoning_effort` outside `xhigh|medium|low` (the `models.yml` fold stays
mandatory); unknown sampling params are a 400; the prompt cache is on by mandatory); unknown sampling params are a 400; the prompt cache is on by
default, so a warm 100k follow-up costs seconds. See default, so a warm 100k follow-up costs seconds (measured after the pool raise:
**81.5 s cold vs 0.3 s warm** at 101,987 prompt tokens). The HTTP surface is
thin — `/health`, `/metrics`, `/v1/models` and POST `/v1/chat/completions`
answer, while `/ping`, `/props`, `/completion` and `/tokenize` are 404 and no
response carries `Server-Timing`: llama.cpp's timing instruments do not exist
on this engine, so measure wall-clock plus `usage.prompt_tokens`. See
[[frank-dragoon-flash-server-v2-swift15]] for the engine-flag detail that [[frank-dragoon-flash-server-v2-swift15]] for the engine-flag detail that
carries verbatim, and [[deployment]] for the deploy/health gate. carries verbatim, and [[deployment]] for the deploy/health gate.
@@ -307,9 +322,17 @@ restart; `webui.db.bak-20261017b` holds the pre-cutover row).
**What the cut cost:** with pool == ctx one maximum-length request fills the **What the cut cost:** with pool == ctx one maximum-length request fills the
pool, so two 200k conversations never coexist (two of ≤100 k do), and the pool, so two 200k conversations never coexist (two of ≤100 k do), and the
harness window is now 155,648 — the 180 k compaction target of the 262,144 era harness window is now 155,648 — the 180 k compaction target of the 262,144 era
is unreachable because the window itself is smaller. (The pool == ctx is unreachable because the window itself is smaller. (That pool == ctx
concurrency limit is superseded — on redeploy only — by *Pool doubled for slot concurrency limit IS superseded: the doubling deployed the next day — see
parity* above; the window math stands, the pool never moves the ceiling.) *Pool doubled for slot parity* above. The window math stands, though: the pool
never moves the per-request ceiling.)
**What it bought:** 1.7 **What it bought:** 1.7
GiB back as page cache for the 47.68 GiB n-gram table (9.1 GiB against the GiB back as page cache for the 47.68 GiB n-gram table (9.1 GiB against the
2026-10-06 shape's 524,288 pool), and a smaller prompt-cache entry. 2026-10-06 shape's 524,288 pool), and a smaller prompt-cache entry.
**Third production boot — the `401408` pool, deployed 2026-10-18.** Same ctx,
same working set, same 111 MiB prompt-cache entry; the pool line reads
`reserving the KV pool, 401408 positions (about 11.0 GiB)` and the total
`79.6 GiB in all`. Ledger and the measured cost in
*Pool doubled for slot parity* above; host `MemAvailable` after that boot:
22 GiB.
@@ -76,8 +76,10 @@ axis improved — decode, prefill, three-stream aggregate, and free VRAM.
**13,056.00 MiB**, ledger-exact. That is exactly HALF the 27B's 34,816 B/token. **13,056.00 MiB**, ledger-exact. That is exactly HALF the 27B's 34,816 B/token.
**f16 KV would bill 26,112 MiB against a 24,123 MiB card** and dies at **f16 KV would bill 26,112 MiB against a 24,123 MiB card** and dies at
`cudaMalloc`. The 24 DeltaNet layers bill a fixed recurrent state per SLOT `cudaMalloc`. The 24 DeltaNet layers bill a fixed recurrent state per SLOT
instead — `RS buffer size = 452.25 MiB (3 cells, 32 layers, 3 seqs)` ≈ 150 MiB instead: the PRODUCTION ledger prints `RS buffer size = 150.75 MiB (3 cells,
each — so pool arithmetic alone under-prices the shape. 32 layers, 3 seqs)` ≈ 50 MiB a cell, while the SPEC-ARMED arms printed
452.25 MiB — MTP depth 2 bills three seqs per slot, so the drafter triples
even the recurrent state. Pool arithmetic alone under-prices the shape.
## The shape (`scripts/frank-zealot.env`) ## The shape (`scripts/frank-zealot.env`)
@@ -123,30 +125,36 @@ inputs plus 2–3 concurrent subagents — so the shipped arm takes the +17 %
prefill and +12 % three-stream aggregate over single-stream speed, and keeps prefill and +12 % three-stream aggregate over single-stream speed, and keeps
6.2 GiB free. Frank confirmed this arm on 2026-10-18. 6.2 GiB free. Frank confirmed this arm on 2026-10-18.
## Boot ledger (deployed arm, verbosity 4) ## Boot ledger (production boot 2026-10-18 13:19, under the unit, verbosity 4)
``` ```
CUDA0 KV buffer size = 13056.00 MiB # q8_0 K + q8_0 V, 786,432 positions CUDA0 model buffer size = 2036.71 MiB # + 257.66 MiB CPU_Mapped (mmap)
CUDA0 RS buffer size = 452.25 MiB # 3 recurrent-state cells, 32 layers, 3 seqs CUDA0 KV buffer size = 13056.00 MiB # q8_0 K + q8_0 V, 786,432 positions
compute 1,409 MiB CUDA + 1,201 MiB CUDA_Host CUDA0 RS buffer size = 150.75 MiB # 3 cells, 32 layers, 3 seqs
mmproj 595 MiB + 248 MiB # charged AFTER the fit check CUDA0 compute buffer = 1409.02 MiB # + 1088.87 MiB CUDA_Host, 2.84 output
nvidia-smi settled: 17,876 MiB used / 6,249 MiB free of 24,123 mmproj worst-case (est.) = 868.32 MiB # 248.10 CUDA + 24.93 CPU, AFTER fit
nvidia-smi settled: 17,922 MiB used / 6,203 MiB free of 24,123
n_slots = 3, n_ctx_slot = 262144, kv_unified = false
``` ```
No clamp line, no `cudaMalloc` error. The engine's own projection (21,439-class No clamp line, no `cudaMalloc` error. The engine's own projection (21,439-class
numbers from the 27B era) excludes the projector — **read settled `nvidia-smi`, numbers from the 27B era) excludes the projector — **read settled `nvidia-smi`,
never the projection**. never the projection**.
## Measured (deployed arm) ## Measured (production boot, `/completion` `timings`, greedy, `ignore_eos`)
- **167.2 t/s** greedy decode (512 tokens, `/completion` `timings`); 158.9 t/s - **168.7 t/s** decode — three runs of 512 tokens: 168.4 / 168.7 / 168.7
single-stream chat with the card's sampler. (the trial arm printed 167.2; same shape, same noise band).
- **280.2 t/s aggregate** across three concurrent streams (≈93.8 each). - **~300 t/s aggregate** across three concurrent 512-token streams
- **2,877 t/s prefill** at 106,418 prompt tokens. (102.8 t/s per stream, round 1 and 2 both) — the trial arm's 280.2.
- **2,877 t/s prefill at 106,418 tokens** — the trial arm; the production boot
has not re-run that depth.
- The 27B record it replaces: 76.6 decode, 109.4 aggregate across two streams, - The 27B record it replaces: 76.6 decode, 109.4 aggregate across two streams,
691.7 prefill at 166k. Beaten on every axis. 691.7 prefill at 166k. Beaten on every axis.
- The **first request after a boot is a warmup artifact** (37.7 t/s class) — - The **first request after a boot is a warmup artifact** (37.7 t/s class) —
never report it. never report it. And never time a wall-clock aggregate on a generation that
stops on its own: a 24-token countdown measured 72 t/s across three streams
while the same shape with `ignore_eos` gave 300.
## Contract traps ## Contract traps
@@ -186,6 +194,21 @@ never the projection**.
7771 — a live port with no unit behind it. `TimeoutStopSec=180` is now on the 7771 — a live port with no unit behind it. `TimeoutStopSec=180` is now on the
unit (llama.cpp needs the drain time to release slots and the card); the unit (llama.cpp needs the drain time to release slots and the card); the
hand-booted arm must be killed by PID, never `pkill -f llama-server`. hand-booted arm must be killed by PID, never `pkill -f llama-server`.
- **A leftover arm drop-in hijacks the production boot, and no health check
sees it.** The sanctioned bench method is a transient
`Environment=FRANK_ZEALOT_ENV_FILE=/tmp/<arm>.env` drop-in, and an arm env
`source`s the repo env and then overrides it. On 2026-10-18 a stale
`frank-zealot.service.d/ptq1.conf` — written 03:29 for the 27B **PTQ1_0** arm
Frank had ordered at 02:36 and measured from 03:14 (deploy ledger below) —
was still installed, so the cutover deploy restarted
the unit and booted THAT: `--model …/Bonsai2-PTQ1_0/Ternary-Bonsai-2-27B-PTQ1_0.gguf
--ctx-size 401408 --parallel 3 --batch-size 3072 --ubatch-size 1536`, three
slots of 133,888, 21,188 MiB used. `/health` was 200, `/v1/models` said
`frank-zealot` (the alias is a flag, not a fact), and the Playwright gate
passed 33/33 — none of those read the payload. **After deploying an endpoint
that was recently benched: `systemctl show -p DropInPaths`, then
`tr '\0' ' ' </proc/$pid/cmdline`, then the ledger — in that order.** Retire
the drop-in to `~/.AGENTTRASH/<date>-<topic>/`, `daemon-reload`, restart.
## Compaction hub ## Compaction hub
@@ -197,6 +220,47 @@ the window ≈ 222,822 est, worst case ≈ 245,891 ≤ 262,144). With `--paralle
a compaction request no longer serializes behind a live generation (the Gemma4 a compaction request no longer serializes behind a live generation (the Gemma4
era's `--parallel 1` law). era's `--parallel 1` law).
## Deploy ledger (2026-10-18)
- `./scripts/deploy.sh dankclaw --service frank-zealot` at 13:16 PDT; the e2e
gate ran 33/33. The first boot was the hijacked arm above; the drop-in went
to `~/.AGENTTRASH/20261018-zealot-ptq1-arm/` and the 13:19 restart is the
ledger recorded here.
- Live probes on the shipped boot: chat `27*59` → **1593**; `where_am_i` tool
call fired with schema-correct empty arguments; 512 px solid-red PNG →
**Red** at 278 prompt tokens with `process_mtmd` chunk encoding in the
journal; `/props` reports `n_ctx 262144` and `presence_penalty 1.0`;
`/metrics` answers 200 with 15 `llamacpp:*` families and
`up{job="frank-zealot"}` is **1** in Prometheus for the first time in its
retained history.
- OpenWebUI picker rows re-keyed in the live DB (`Connection.backup()` snapshot
`webui.db.bak-20261018` taken first; `frank-dragoon`'s `context.length` also
corrected 262,144 → 200,704 — see [[OpenWebUI]]).
- **What the bench window left behind is not garbage — the PTQ1_0 arm was
ordered, measured, and then scratched by Frank himself.** The instruction at
**02:36** was: run frank-zealot on `Ternary-Bonsai-2-27B-PTQ1_0.gguf`, the
**Q8** mmproj, batch/ubatch **3072/1536**, and as much context as fits in
**two** contexts. The unit booted it at **03:14** (journal:
`type ptq1_0: 402 tensors`, `file type = PTQ1_0 - 1.75 bpw ternary`, mmproj
loaded, 2 × 200,704) and served 24 minutes of traffic. Its own
`print_timing` lines measure **~48 t/s** single-stream decode
(48.0 / 47.9 / 47.6 / 49.0 t/s on 512 / 229 / 512 / 451-token generations),
25–27 t/s per stream with two streams live, and prefill **1,295 t/s at
30,637 → 943 at 97,856 → 801 t/s at 141,365** tokens. It bought the
1,201 MiB the fetch script promised and paid for it on decode, exactly as the
publisher's Ampere row warned (the PQ2_0 record for the same weights is
76.6 t/s). At **03:33** Frank scratched the whole 27B plan and ordered the
9B PQ2_0 + MTP shape deployed here; the arm process ended at 03:38.
- The actual failure was **documentation, not measurement.** An approved
payload that survives only as a systemd drop-in plus a journal window is,
a day later, indistinguishable from a rogue arm — which is why the 13:16 boot
read as a hijack. Payload decisions belong in this runbook the moment they
are made. The evidence is still perishable: `/tmp/zealot-arm-current.env` and
the 9B `/tmp/arm-*.jsonl` results die on reboot, and only the journal
(`journalctl -u frank-zealot --since "2026-10-18 03:10"`) holds the PTQ1_0
numbers. `/home/dank/models/Bonsai2-PTQ1_0/` (5,946,648,928 B + hardlinked
mmproj) stays on disk as the 27B rollback.
## Fallback rungs ## Fallback rungs
1. `UBATCH_SIZE=1024` — frees **1,306 MiB** measured on this shape (prefill 1. `UBATCH_SIZE=1024` — frees **1,306 MiB** measured on this shape (prefill
+14 -16
View File
@@ -22,14 +22,15 @@ and Dankclaw runs two endpoints — **halogen-flash-server 0.16.2
digest serving the NATIVE v2 conversion of the abliterated digest serving the NATIVE v2 conversion of the abliterated
Qwen3.8-Flash-Next, a ~62 GiB v2 .hgn that loads in no other engine, Qwen3.8-Flash-Next, a ~62 GiB v2 .hgn that loads in no other engine,
MTP spec decode always-on (HALOGEN_SPEC_ADAPT=0), TWO slots over ONE MTP spec decode always-on (HALOGEN_SPEC_ADAPT=0), TWO slots over ONE
200,704-position KV pool at ctx 200704 — the 2 × ctx = 401,408 raise is 401,408-position KV pool — 2 × ctx, raised and DEPLOYED 2026-10-18 — at ctx
committed 2026-10-18 but NOT yet deployed, vision ON)** and 200704, vision ON)** and
**llama.cpp on the PrismML fork (frank-zealot, port 7771: prism-ml **llama.cpp on the PrismML fork (frank-zealot, port 7771: prism-ml
Ternary-Bonsai-2-27B at **PQ2_0** — 27B qwen35 hybrid (16 full-attention + 48 Ternary-Bonsai-2-9B at **PQ2_0** — 9B qwen35 hybrid (8 full-attention + 24
Gated DeltaNet layers), TWO 200,704-token conversations, MTP disabled, Gated DeltaNet layers + the blk.32 MTP block), THREE 262,144-token
conversations, MTP present in the file but OFF by measurement,
q8_0 K + q8_0 V (f16 KV does not fit the RTX 3090 at this window), vision via q8_0 K + q8_0 V (f16 KV does not fit the RTX 3090 at this window), vision via
the publisher's own Q8_0 Qwen3-VL mmproj, on the RTX 3090 — and the custom the publisher's own Q8_0 mmproj, on the RTX 3090 — and the custom
tensor type 142 + `prism.hadamard.*` metadata mean no other loader can open the tensor type 141 + `prism.hadamard.*` metadata mean no other loader can open the
file)**. file)**.
> 2026-08-22: the former `vllmScripts` repository is merged into the > 2026-08-22: the former `vllmScripts` repository is merged into the
@@ -58,11 +59,11 @@ gfx1151 iGPU 114 GiB GTT]
E[flash-server container gfx1151, digest-pinned image E[flash-server container gfx1151, digest-pinned image
frank-dragoon, port 7770] frank-dragoon, port 7770]
F[flash-server v2 native abliterated .hgn + ngram table + vision tower F[flash-server v2 native abliterated .hgn + ngram table + vision tower
ONE 200704-position KV pool at ctx 200704 (401408 committed, awaiting deploy), 2 slots, MTP always-on, image input] ONE 401408-position KV pool at ctx 200704 = 2 slots x ctx, MTP always-on, image input]
G[llama.cpp PrismML fork CUDA build G[llama.cpp PrismML fork CUDA build
frank-zealot, port 7771] frank-zealot, port 7771]
H[Ternary-Bonsai-2-27B PQ2_0 GGUF + Q8_0 mmproj H[Ternary-Bonsai-2-9B PQ2_0 GGUF + Q8_0 mmproj
2 x 200704 ctx, q8_0 K + q8_0 V, MTP off, mmproj vision] 3 x 262144 ctx, q8_0 K + q8_0 V, MTP off, mmproj vision]
E2 --> E --> F E2 --> E --> F
E2 --> G --> H E2 --> G --> H
end end
@@ -94,13 +95,10 @@ frank-zealot, port 7771]
`HALOGEN_CTX=200704` over `HALOGEN_KV_POOL_POSITIONS=401408` (**2 × ctx**): `HALOGEN_CTX=200704` over `HALOGEN_KV_POOL_POSITIONS=401408` (**2 × ctx**):
**one pool behind two slots**, sized so TWO maximum-length requests coexist — **one pool behind two slots**, sized so TWO maximum-length requests coexist —
raised from 200,704 (5.5 GiB) on 2026-10-18 for frank-zealot parity on total raised from 200,704 (5.5 GiB) on 2026-10-18 for frank-zealot parity on total
in-flight context, ~10.8 GiB reserved against the card's 262,144/262,144 = in-flight context and **deployed the same evening**: the boot ledger reserves
7.2 GiB. **Deploy state 2026-10-18: the running container and the `401408 positions (about 11.0 GiB)` with `79.6 GiB in all`
host-deployed env still carry the 200,704-position pool** (boot ledger (`scripts/frank-dragoon.env:70-75`). It bought CONCURRENCY, not a longer
`reserving the KV pool, 200704 positions (about 5.5 GiB)`; host window: the per-request ceiling stays 200,704, because 262,144 is the
`frank-dragoon.env` `HALOGEN_KV_POOL_POSITIONS=200704`) — the doubling lands
on the next redeploy (commit `2b12716`).
longer window: the per-request ceiling stays 200,704, because 262,144 is the
native wall and the engine refuses more without `HALOGEN_ROPE_YARN`. native wall and the engine refuses more without `HALOGEN_ROPE_YARN`.
MTP head always on, sampler 1.0/0.95/20, coupled cap/timeout MTP head always on, sampler 1.0/0.95/20, coupled cap/timeout
65536/7200, prompt cache ON, vision ON. Measured against the Swift1.5 65536/7200, prompt cache ON, vision ON. Measured against the Swift1.5
+3 -2
View File
@@ -314,8 +314,9 @@ The LLM inference fleet on the two hosts. Full index:
since 2026-10-17**: halogen-flash-server 0.16.2 serving the **NATIVE v2 since 2026-10-17**: halogen-flash-server 0.16.2 serving the **NATIVE v2
abliterated** conversion of orcarouter's pinned BF16 abliterated** conversion of orcarouter's pinned BF16
(`qwen38-flash-next-v2-abliterated.hgn`, 62.1 GiB) at ctx **200,704** over a (`qwen38-flash-next-v2-abliterated.hgn`, 62.1 GiB) at ctx **200,704** over a
**401,408**-position pool (`HALOGEN_KV_POOL_POSITIONS=401408`, ~10.8 GiB — one **401,408**-position pool (`HALOGEN_KV_POOL_POSITIONS=401408`, 11.0 GiB
pool behind both slots, raised to 2 × ctx on 2026-10-18 so TWO maximum-length reserved — one pool behind both slots, raised to 2 × ctx and deployed
2026-10-18 so TWO maximum-length
requests coexist) and the stock peonist n-gram/vision companions **hardlinked** requests coexist) and the stock peonist n-gram/vision companions **hardlinked**
in — engine-tool certified (NIAH 18/18, PPL 3.968, mean KL 0.155); MTP in — engine-tool certified (NIAH 18/18, PPL 3.968, mean KL 0.155); MTP
always-on, vision ON, no server-side sampler change always-on, vision ON, no server-side sampler change
+154
View File
@@ -2156,4 +2156,158 @@ every restart since the cutover.
picker row (DB `description` + context length 262,144) re-keyed. Rollback: picker row (DB `description` + context length 262,144) re-keyed. Rollback:
revert the swap commit — the 27B payload and the build-10754 fork tree are revert the swap commit — the 27B payload and the build-10754 fork tree are
both still on disk. both still on disk.
- Deployed 13:16 PDT (`deploy.sh dankclaw --service frank-zealot`, e2e 33/33)
— and the first boot was **not the shipped payload**: a leftover
`frank-zealot.service.d/ptq1.conf` from the bench window (written 03:29 for
the 27B **PTQ1_0** arm Frank ordered at 02:36 and measured from 03:14 — see
the record below) pointed
`FRANK_ZEALOT_ENV_FILE` at `/tmp/zealot-arm-current.env`, so the unit booted
that arm: 3 slots of 133,888, 21,188 MiB used, batch 3072/ubatch 1536.
`/health` 200, `/v1/models` echoed the alias, Playwright 33/33 — nothing in
the gate reads the payload. Drop-in moved to
`~/.AGENTTRASH/20261018-zealot-ptq1-arm/`, `daemon-reload`, restart at 13:19:
`n_slots = 3, n_ctx_slot = 262144`, KV 13,056.00 MiB, settled 17,922/6,203.
- Production measures (engine `timings`, greedy, `ignore_eos`): **168.7 t/s**
decode (168.4/168.7/168.7) and **~300 t/s** aggregate across three 512-token
streams (102.8 each). My first battery reported 117 and 72 t/s: it timed
natural generations that stopped at 24 tokens — wall-clock aggregate on an
early EOS reads 4× low. `ignore_eos` + the engine's own numbers is the only
honest way to time this.
- Ledger correction: the shipped shape prints **RS 150.75 MiB** (3 cells), not
the 452.25 MiB my first-pass notes carried — that figure belongs to the
SPEC-ARMED arms, where MTP depth 2 bills three seqs per slot (9 cells). The
drafter triples the recurrent state as well as billing 3,072 MiB of f16 KV.
Corrected in AGENTS.md (topology row + architecture section), the env header
and the runbook, with the production boot ledger replacing the trial's.
- Verified live: chat `27*59` → 1593; `where_am_i` tool call fired; 512 px
solid-red PNG → **Red** (278 prompt tokens, `process_mtmd` in the journal);
`/props` `n_ctx 262144`, `presence_penalty 1.0`; `/metrics` 200 with 15
`llamacpp:*` families and `up{job="frank-zealot"} = 1` — the first non-zero
sample in that job's retained history.
- OpenWebUI `model` rows re-keyed in the live DB (backup
`webui.db.bak-20261018`): `frank-zealot` → 262,144 + the 9B description, and
`frank-dragoon`'s `context.length` corrected 262,144 → **200,704** (it had
kept the pre-lowering window from 2026-10-17 while its own description said
200,704). Note the columns are `params`/`meta` JSON — the table has no
`context_length`/`description` columns.
## 2026-10-18 — frank-zealot PTQ1_0 arm: measured, approved, and never written down
- **Correction to the entry above and to my own report.** The
`frank-zealot.service.d/ptq1.conf` drop-in was NOT a half-finished experiment.
The session archive (`01a14c93`) carries Frank's own instruction at **02:36**:
replace frank-zealot with `Ternary-Bonsai-2-27B-PTQ1_0.gguf`, use the **Q8**
mmproj, batch/ubatch **3072/1536**, "fit as much context as possible in 2
contexts".
- It was the production payload **03:14:39 → 03:38:57** (pid 191358; journal
`type ptq1_0: 402 tensors`, `file type = PTQ1_0 — 1.75 bpw ternary`, mmproj
loaded, 2 × 200,704) and served real traffic. Recovered from that process's
own `print_timing` lines (17 timed tasks): single-stream greedy decode
**48.0 and 47.6 t/s at 512 tokens, 47.9 at 229, 49.0 at 451**; 25–27 t/s per
stream with two streams live; prefill **1,295 t/s @ 30,637 → 943 @ 97,856 →
801 t/s @ 141,365** (one 176 t/s row at 97,024 is a concurrent-slot request,
not a clean probe).
- Against the PQ2_0 27B record (76.6 t/s decode, 691.7 t/s prefill at 166k):
PTQ1_0 freed the **1,201 MiB** its fetch script promised and paid **−37 %
decode** — the publisher's "dense ternary unpacking loses on Ampere" warning,
now confirmed on GA102 — while prefilling deep prompts faster than that
record ever managed. Frank scratched the whole 27B plan at **03:33** ("scratch
the current frank-zealot plan completely… 9B… enable mtp… if you can possibly
fit a third context, do it"), which is what shipped at 13:16.
- The durable failure is mine: those numbers lived only in `/tmp` and the
journal, never in the repo, so the next agent on the host could not tell an
approved payload from a rogue drop-in and called it a hijack. The runbook
deploy ledger now carries the measurement; `/home/dank/models/Bonsai2-PTQ1_0/`
stays on disk as the 27B rollback and the drop-in stays trashed.
## 2026-10-18 — frank-dragoon: the 401,408-position pool goes live; docs re-keyed to the measured boot
- `./scripts/deploy.sh dankclaw --service frank-dragoon` — e2e 33/33. The
drop-in check the zealot incident bought ran FIRST: `DropInPaths` empty, so
the repo env governed. Boot ledger: `reserving the KV pool, 401408 positions
(about 11.0 GiB)`, `2 slots over ONE 401408-position KV pool, each request up
to 200704`, and `62.9 GiB of weights locked … 11.0 GiB of KV pool, 5.7 GiB of
working memory, 79.6 GiB in all`; prompt cache ON (12 entries × 111 MiB at
ctx 200704 = 1.3 GiB), n-gram 47.68 GiB still `Pin::None`, no clamp line.
- Warm reuse survived the raise, measured right after the deploy: one
**101,987**-token wikitext prompt through `/v1/chat/completions` took
**81.5 s** cold (1,251 t/s of prefill) and **0.3 s** on each of two repeats —
`HALOGEN_CACHE_INPLACE=1` keeps the KV rows in the pool, so a warm turn
prefills nothing and the doubling evicted nothing the cache needed. The RAM
price is still real: host `MemAvailable` fell **26 → 22 GiB**, because every
pool position is RAM the n-gram table's page cache loses.
- Every document quoted **~10.8 GiB**, the ~28 KiB/position arithmetic; the
engine reserves **11.0 GiB** and its preflight bills **11.3**. Re-keyed in
AGENTS.md (topology row, KV-pool law, memory ledger), the `frank-dragoon.env`
lore block, both index pages, the iommu page and the runbook, and every
"committed but not yet deployed" clause was retired with the measurement.
- The same sweep caught the LLM index still describing frank-zealot as
**Bonsai-2-27B at 2 × 200,704** in its intro paragraph and topology diagram —
the 9B swap of the same day had not reached those two spots. Re-keyed: 8
full-attention + 24 Gated DeltaNet + the `blk.32` MTP block, 3 × 262,144,
tensor type **141**.
- PTQ1_0 provenance corrected (see the dedicated section above): the arm that
hijacked the zealot deploy was a **repo-tracked, Frank-ordered, measured**
candidate — `scripts/download-frank-zealot-bonsai2-ptq1.sh` is committed, and
its header now carries the recovered numbers (≈48 t/s decode, 801 t/s prefill
at 141k) and the verdict: −37 % decode against PQ2_0 for 1,201 MiB freed, then
scratched by Frank's 03:33 order in favour of the 9B. Nothing is owed on it.
- OpenWebUI: both decoration rows re-keyed in the live DB. The values live
inside JSON columns — `params.context.length` and `meta.description` — not in
fields named for them, and there is no `sqlite3` binary on the host, so
`python3` stdlib + `Connection.backup()` (`webui.db.bak-20261018`). Zealot
200,704 → **262,144** with the 9B description; dragoon 262,144 → **200,704**,
which had kept the pre-lowering window while its own description said
200,704.
- Census of the halogen HTTP surface while doing it, because the fleet bench
law quoted the wrong engine: frank-dragoon answers **`/health`, `/metrics`,
`/v1/models`, `/v1/chat/completions`(POST)** and 404s `/ping`, `/props`,
`/completion`, `/tokenize`; the chat response carries **no `Server-Timing`**
header (`server: uvicorn`, only `date`/`content-*`). So the `/completion`
`timings` object and the `ttft;dur=` header are **llama.cpp** instruments —
frank-zealot's, not dragoon's. AGENTS.md's Benchmarking Policy re-keyed to
say which engine owns which instrument.
## 2026-10-18 — frank-tts phase 1: the cloned-voice endpoint, named and tested (not deployed)
- Voice-over goal, phase 1 (Gitea issue #2). New service **frank-tts** on
Dankclaw: `dankclaw/voiceservice/` (stdlib-only `app.py` + `Containerfile` +
rootless quadlet), `scripts/frank-tts.env`, `tests/frank_tts_unit.bats`
(12 tests). One OpenAI-shaped `POST /v1/audio/speech` surface; `voice=<name>`
is honoured only when `/voices/<name>/reference.wav` exists, otherwise a 400
listing the voices that are — no silent fallback voice. `voice_id` is the
harness alias; `response_format=mp3` really is mp3; oversized input is a 414,
not a half-narrated sentence; `/health` reports backend + voice list; no audio
ever reaches the log. Two backends behind it (`TTS_BACKEND=omni-http` forwards
to the host's `:8880` engine with the voice as base64 `reference_audio`;
`llama-tts-cli` runs a binary per request at `nice -n 19`), both exercised
through stubs — no GPU, no containers in the seam.
- **Naming law applied (Frank's call):** treat it as a first-class model
endpoint → alias-named `frank-tts` (TTS endpoint, so not `frank-asr`),
**port 7772**, joining `7770 frank-dragoon` / `7771 frank-zealot`; the engine
upstream keeps 8880 and is deliberately not a fleet endpoint. The internal
knob names stay `SHIM_*` (they name the façade component, not the service).
- **Voice-pack law recorded** in the env header: a pack needs `reference.wav`
(24 kHz mono, 18–25 s) **and** the pinned engine's own speaker artifact —
required, beside the WAV, never replacing it — plus `manifest.json` provenance
whose offsets are always past the first 10 s of the source (the
source-material law). Enforcement is split honestly: the shim gates on
`reference.wav` (the only engine-agnostic term); the pack builder is what will
refuse to emit a pack without its artifact.
- Suite green: **384/384** (`./tests/run.sh`, wall 66 s). Landed as **PR #4**
(branch `wt/20261017-181502` → `main`, merged as `49c4e19`) — the first PR on
this repo through the API, which cost three 422s to get right: the merge
endpoint takes **`{"Do":"merge"}`**, `Do` being a *string* naming the strategy.
`DoMerge`/`merge_method` do not exist on this Gitea; swagger JSON is disabled
on the instance, so the 422 messages are the schema. Law saved to the
`gitea-api-pr-operations` skill.
- Deliberately NOT in this phase: the `:8880` engine decision and its
speaker-artifact format, the pack builder, deployer/homepage/Prometheus
registration, the `models.yml` row that lets omp call it, and open-notebook.
Harness fact established while writing the PR body: the **`tts` tool** will
dispatch an `api: openai-speech` provider row to `POST <baseUrl>/audio/speech`
(`{model, input, response_format, voice}`), but the live `speech.*` vocalizer is
Kokoro-enum-only — so "the agent talks in my voice" needs that provider row or
a `pi.registerProvider` extension, decided in phase 2.
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env bats
# Seam: frank-tts, the cloned-voice speech façade on Dankclaw :7772.
#
# The contract this pins is the one every consumer (omp, OpenWebUI,
# open-notebook) codes against: POST /v1/audio/speech with voice=<name> is
# answered with audio in THAT voice pack, where "that voice pack" is exactly
# $VOICES_DIR/<name>/reference.wav — the file that makes a name real. A name
# without a reference must come back as a 400 naming the voices that do exist
# (a silent fallback voice is how a wrong-voice endpoint stays wrong for
# weeks), the harness' voice_id must work as an alias for voice, oversized text
# must be refused rather than half-narrated, and the mp3 response_format must
# really be mp3. Whatever engine sits behind it is a separate decision — so
# both backends are driven here through stubs, never against a GPU.
#
# Everything runs against the real app.py over 127.0.0.1 ephemeral ports with
# a temp VOICES_DIR: no Dankclaw, no network, no containers, no pkill — the
# processes this file starts are recorded by pid and killed in teardown.
load helpers
setup() {
APP="$REPO_ROOT/dankclaw/voiceservice/app.py"
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts.container"
ENV_FILE="$REPO_ROOT/scripts/frank-tts.env"
TEST_TMP="$(mktemp -d)"
PIDS="$TEST_TMP/pids"
: > "$PIDS"
# One real voice pack and one directory that LOOKS like a pack but has no
# reference.wav — it must never be offered as a voice.
VOICES="$TEST_TMP/voices"
mkdir -p "$VOICES/narrator1" "$VOICES/nopack"
python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(range(256)) * 4)' \
> "$VOICES/narrator1/reference.wav"
printf 'a manifest is not a voice\n' > "$VOICES/nopack/manifest.json"
# The audio the stub upstream "synthesises": distinct bytes, so a response
# can be proven to be the engine's output and not something the shim made
# up on its way past.
python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(reversed(range(128))) * 3)' \
> "$TEST_TMP/upstream.wav"
write_stub
}
teardown() {
# Kill only what this file started, by recorded pid.
while IFS= read -r pid; do
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
done < "$PIDS"
wait 2>/dev/null || true
rm -rf "$TEST_TMP"
}
# write_stub — the upstream TTS server arm A forwards to: records the JSON body
# it was handed (so "did the reference voice actually travel?" is observable)
# and answers with $STUB_WAV. Prints its bound port, chosen by the kernel.
write_stub() {
cat > "$TEST_TMP/stub_upstream.py" <<'PY'
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
class Stub(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('content-length') or 0)
with open(os.environ['STUB_CAPTURE'], 'w') as fh:
json.dump(json.loads(self.rfile.read(length) or b'{}'), fh)
with open(os.environ['STUB_WAV'], 'rb') as fh:
audio = fh.read()
self.send_response(200)
self.send_header('content-type', 'audio/wav')
self.send_header('content-length', str(len(audio)))
self.end_headers()
self.wfile.write(audio)
def log_message(self, *args):
pass
server = HTTPServer(('127.0.0.1', 0), Stub)
print(server.server_address[1], flush=True)
server.serve_forever()
PY
}
start_stub() {
: > "$TEST_TMP/stub.log"
STUB_WAV="$TEST_TMP/upstream.wav" STUB_CAPTURE="$TEST_TMP/capture.json" \
python3 "$TEST_TMP/stub_upstream.py" > "$TEST_TMP/stub.log" 2>&1 &
echo "$!" >> "$PIDS"
local waited=0
while [ "$waited" -lt 100 ]; do
STUB_PORT="$(head -n1 "$TEST_TMP/stub.log")"
[ -n "$STUB_PORT" ] && return 0
sleep 0.05
waited=$((waited + 1))
done
echo "stub upstream never bound a port:"
cat "$TEST_TMP/stub.log"
return 1
}
# start_app — boot app.py on 127.0.0.1 with an ephemeral port and read that
# port back off its own startup line: no port guessing, no race. The caller
# exports the env the run needs first; app.py reads its knobs at request time.
start_app() {
: > "$TEST_TMP/app.log"
python3 "$APP" --host 127.0.0.1 --port 0 > "$TEST_TMP/app.log" 2>&1 &
echo "$!" >> "$PIDS"
local waited=0
while [ "$waited" -lt 200 ]; do
APP_PORT="$(sed -nE 's|.*listening on http://127\.0\.0\.1:([0-9]+)|\1|p' "$TEST_TMP/app.log" | head -n1)"
[ -n "$APP_PORT" ] && return 0
sleep 0.05
waited=$((waited + 1))
done
echo "frank-tts never came up:"
cat "$TEST_TMP/app.log"
return 1
}
# arm_omni — the shim forwarding to an OpenAI-shaped upstream (arm A).
arm_omni() {
start_stub
export VOICES_DIR="$VOICES"
export TTS_BACKEND=omni-http
export TTS_UPSTREAM="http://127.0.0.1:$STUB_PORT"
}
speech() {
# speech <json-payload-inline> — the HTTP code lands in $output, the body
# in $TEST_TMP/out.bin and the response headers in head.txt.
local data="$1"
run curl -sS -o "$TEST_TMP/out.bin" -D "$TEST_TMP/head.txt" -w '%{http_code}' \
-H 'Content-Type: application/json' --data-binary "$data" \
"http://127.0.0.1:$APP_PORT/v1/audio/speech"
}
@test "app.py parses as python the runtime can import" {
run env PYTHONPYCACHEPREFIX="$TEST_TMP/pycache" python3 -m py_compile "$APP"
[ "$status" -eq 0 ] || { echo "$output"; false; }
}
@test "voice=narrator1 returns the engine's audio with the pack's reference attached" {
arm_omni
start_app
speech '{"model":"tts-1","input":"hello world","voice":"narrator1"}'
[ "$status" -eq 0 ] || { cat "$TEST_TMP/app.log"; false; }
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
grep -qi '^content-type: audio/wav' "$TEST_TMP/head.txt"
cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav"
# The voice only arrives at the engine as base64 reference_audio — prove it
# is the pack's bytes, not a path and not the engine's own default voice.
python3 - "$TEST_TMP/capture.json" "$VOICES/narrator1/reference.wav" <<'PY'
import base64, json, sys
body = json.load(open(sys.argv[1]))
reference = open(sys.argv[2], 'rb').read()
assert body['voice'] == 'narrator1', body
assert body['input'] == 'hello world', body
assert body['response_format'] == 'wav', body
assert base64.b64decode(body['reference_audio']) == reference, 'reference_audio is not the pack wav'
PY
# What a consumer discovers before it speaks: /health and the voice list.
# A directory with a manifest but no reference.wav is not a voice.
run curl -sS "http://127.0.0.1:$APP_PORT/v1/audio/voices"
[ "$output" = '["narrator1"]' ]
run curl -sS "http://127.0.0.1:$APP_PORT/health"
[[ "$output" == *'"status": "ok"'* ]]
[[ "$output" == *'"backend": "omni-http"'* ]]
[[ "$output" == *'"voices": ["narrator1"]'* ]]
}
@test "voice_id works as the alias the omp harness sends" {
arm_omni
start_app
speech '{"model":"tts-1","input":"hello","voice_id":"narrator1"}'
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav"
python3 - "$TEST_TMP/capture.json" <<'PY'
import json, sys
body = json.load(open(sys.argv[1]))
assert body['voice'] == 'narrator1', 'voice_id never became the upstream voice'
assert 'voice_id' not in body, body
PY
}
@test "an unknown voice is a 400 naming the voices that exist" {
arm_omni
start_app
speech '{"model":"tts-1","input":"hello","voice":"nosuch"}'
[ "$output" = "400" ]
grep -q 'narrator1' "$TEST_TMP/out.bin"
# Refused before the engine saw anything: no audio, no upstream call.
[ ! -s "$TEST_TMP/capture.json" ]
}
@test "oversized input is refused outright, not narrated half-way" {
arm_omni
# The limit is a knob the operator sets, not a constant in the seam: the
# app reads its knobs at request time, so exporting it before boot works.
export SHIM_MAX_CHARS=10
start_app
python3 - "$TEST_TMP/payload.json" <<'PY'
import json, sys
json.dump({'model': 'tts-1', 'input': 'A' * 11, 'voice': 'narrator1'},
open(sys.argv[1], 'w'))
PY
run curl -sS -o "$TEST_TMP/out.bin" -w '%{http_code}' \
-H 'Content-Type: application/json' --data-binary "@$TEST_TMP/payload.json" \
"http://127.0.0.1:$APP_PORT/v1/audio/speech"
[ "$output" = "414" ]
[ ! -s "$TEST_TMP/capture.json" ]
}
@test "every other route is a 404, GET or POST" {
arm_omni
start_app
run curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$APP_PORT/v1/audio/speeck"
[ "$output" = "404" ]
run curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' -d '{"input":"hi","voice":"narrator1"}' \
"http://127.0.0.1:$APP_PORT/v1/speech"
[ "$output" = "404" ]
}
@test "an unrouted POST does not poison a reused connection" {
# The shim speaks HTTP/1.1, so a client may keep one TCP connection alive
# across requests. A body left unread on the way to a 404 is parsed as the
# NEXT request line — the consumer would blame the following, perfectly
# valid call. So: 404 first, then /health on the same connection.
arm_omni
start_app
python3 - "$APP_PORT" <<'PY'
import http.client, json, sys
conn = http.client.HTTPConnection("127.0.0.1", int(sys.argv[1]), timeout=15)
conn.request("POST", "/v1/audio/speeck",
body=json.dumps({"input": "hi", "voice": "narrator1"}),
headers={"Content-Type": "application/json"})
first = conn.getresponse()
assert first.status == 404, first.status
first.read()
conn.request("GET", "/health")
second = conn.getresponse()
assert second.status == 200, second.status
assert json.loads(second.read())["status"] == "ok"
conn.close()
PY
}
@test "the CLI backend gets the resolved reference as --tts-speaker-file" {
# A fake binary standing in for llama-tts: it records its argv, writes a
# WAV where --output points, and reports the temp dir it was asked to work
# in so the cleanup promise is observable too.
cat > "$TEST_TMP/fake-tts" <<'FAKE'
#!/usr/bin/env bash
printf '%s\n' "$@" > "$ARGS_FILE"
prev=""
for arg in "$@"; do
if [ "$prev" = "--output" ]; then
printf 'CLI-AUDIO' > "$arg"
dirname "$arg" > "$WORKDIR_FILE"
fi
prev="$arg"
done
exit 0
FAKE
chmod +x "$TEST_TMP/fake-tts"
printf 'CLI-AUDIO' > "$TEST_TMP/expected.bin"
export VOICES_DIR="$VOICES"
export TTS_BACKEND=llama-tts-cli
export LLAMA_TTS_BIN="$TEST_TMP/fake-tts"
export LLAMA_TTS_MODEL="$TEST_TMP/model.bin"
export ARGS_FILE="$TEST_TMP/argv.txt"
export WORKDIR_FILE="$TEST_TMP/workdir.txt"
start_app
speech '{"model":"tts-1","input":"narrate this","voice":"narrator1"}'
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
grep -qi '^content-type: audio/wav' "$TEST_TMP/head.txt"
cmp "$TEST_TMP/out.bin" "$TEST_TMP/expected.bin"
# The speaker file must be the pack's reference under VOICES_DIR — that is
# the whole product; anything else and the clone never happens.
speaker="$(awk 'p { print; exit } $0 == "--tts-speaker-file" { p = 1 }' "$TEST_TMP/argv.txt")"
[ "$speaker" = "$VOICES/narrator1/reference.wav" ]
prompt="$(awk 'p { print; exit } $0 == "-p" { p = 1 }' "$TEST_TMP/argv.txt")"
[ "$prompt" = "narrate this" ]
grep -qx -- '--tts-lang' "$TEST_TMP/argv.txt"
# A per-request temp dir that survives is how a TTS box fills its /tmp.
workdir="$(cat "$TEST_TMP/workdir.txt")"
[ -n "$workdir" ]
[ ! -d "$workdir" ]
}
@test "response_format=mp3 ships mp3, converted from the engine's wav" {
arm_omni
# A fake ffmpeg on PATH: whatever wav arrives on stdin is tapped (so the
# conversion provably ran on the engine's audio) and mp3 bytes come out.
mkdir -p "$TEST_TMP/bin"
cat > "$TEST_TMP/bin/ffmpeg" <<'FF'
#!/usr/bin/env bash
cat > "$STDIN_TAP"
printf 'MP3-BYTES'
FF
chmod +x "$TEST_TMP/bin/ffmpeg"
export STDIN_TAP="$TEST_TMP/ffmpeg-in.wav"
# The fake must reach the APP's PATH: the shim runs `ffmpeg` by name.
export PATH="$TEST_TMP/bin:$PATH"
start_app
speech '{"model":"tts-1","input":"hello","voice":"narrator1","response_format":"mp3"}'
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
grep -qi '^content-type: audio/mpeg' "$TEST_TMP/head.txt"
printf 'MP3-BYTES' > "$TEST_TMP/expected-mp3.bin"
cmp "$TEST_TMP/out.bin" "$TEST_TMP/expected-mp3.bin"
cmp "$TEST_TMP/ffmpeg-in.wav" "$TEST_TMP/upstream.wav"
# The upstream is still asked for wav: mp3 is this shim's re-encode.
python3 - "$TEST_TMP/capture.json" <<'PY'
import json, sys
assert json.load(open(sys.argv[1]))['response_format'] == 'wav'
PY
}
@test "the shipped env file boots the shim with its declared backend" {
# Not a grep of the env file: source it the way systemd's EnvironmentFile
# would, point the two machine-local values at the sandbox, and require the
# endpoint to actually speak. A typo, a shell-ism or an expansion systemd
# cannot parse shows up here as a dead process, not a green grep.
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
arm_omni
start_app
speech '{"model":"tts-1","input":"hello","voice":"narrator1"}'
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav"
}
@test "the quadlet unit keeps boot persistence and a read-only voice mount" {
grep -qE '^Restart=always$' "$UNIT"
grep -qE '^RestartSec=[0-9]+$' "$UNIT"
grep -qE '^WantedBy=default.target$' "$UNIT"
grep -qE '^PublishPort=([0-9.]+:)?7772:7772$' "$UNIT"
grep -qE '^Volume=/home/dank/voices:/voices:ro$' "$UNIT"
}