Frank's order, 2026-10-19: --parallel 3 at 200,704 a conversation (pool
602,112), K at the engine default (f16), V to q8_0, batch 1024x6 / ubatch
1024, and the MTP drafter armed explicitly.
The three requests are one budget equation. At 8,192 elements per token per
cache the pair now bills 25,088 B/token = 14,406.00 MiB over the 602,112
pool, and the armed draft context adds its own f16 KV over that whole pool
(4,096 B/position = 2,352.00 MiB) plus buffers and recurrent cells. Dropping
V to the 8-bit class frees 4,410 MiB against exact V and the ubatch drop
hands back ~1,306 - that is what pays for the third slot AND the drafter,
neither of which exact V could fund.
The serve script's written-down fallbacks move with the env so a missing env
file cannot boot the retired shape, and the seam now: asserts the per-slot
contract (divides evenly, <= the trained 262,144), bills an armed drafter in
the VRAM fit gate, proves the gate still REFUSES both boots this shape can
walk into, pins the shipped MTP set (and the absence of --spec-draft-model,
which would load the trunk twice), and pins SPEC_TYPE=none as the rollback
that strips the whole set.
Key Lessons Learned 483 -> 340 lines. Every law survives; only the essays
went. Port-before-cut census over the diff: 454 deleted lines, 0 orphaned
facts (every number still has a holder in the vault).
- Each lesson is now one bullet with the page that owns the detail.
- De-duplicated the page's own internal repeats: the layer-split law was
told twice (PCIe and ROCm sections), the FA-quants law twice more.
- CORRECTION, verified on dankclaw 2026-10-19: `GGML_CUDA_FA_ALL_QUANTS` is
deprecated upstream (master's ggml/cmake/common.cmake tells you to use
GGML_CUDA_FA_QUANTS=all), the successor's default pair list
`q4_0-q4_0;q8_0-q8_0;f16-f16;bf16-bf16` compiles the quantized-KV kernels
with the old flag OFF (master build-cuda cache: FA_ALL_QUANTS=OFF), and
neither llama tree references the old macro under ggml/src/ggml-cuda/.
The "required or prefill collapses ~12x" law is obsolete; the new law is
to read the CMakeCache pair list.
- frank-zealot: ported the three facts AGENTS.md alone carried, and verified
them against the host first — the 2026-10-18 13:16 cutover boot really did
load PTQ1_0 at `new slot, n_ctx = 133888` x3 (journal), the mmproj fit
check really printed 873.10 MiB against a 21,439 MiB projection, and the
warmup spread is 2.6 -> 1,258 t/s prefill.
- New tooling: scripts/check-okf.py (OKF v0.1 conformance linter) and
scripts/gen-vault-index.py (root index regenerated from frontmatter).
Co-Authored-By: omp <omq@openclaw.sh>
The 2026-10-19 vault de-dup campaign sent four read-only scouts over the same
pages and had to parent-verify every claim: one inverted era-page law (flagged
pages stale for documenting the retired payloads they exist to record), one
listed files that do not exist, one reported duplications grep disproved, one
cited a line past the end of the file it judged. Record the rule beside the
other evidence laws so the next agent does not integrate a yield unchecked.
Refs: vault de-dup campaign, PR #24.
The "`--swap-space` is removed; use `--kv-offloading-size`" claim was carried by
six pages, two of them with their own copy of the upstream source citation. It
now lives only in [[RAM CPU Offloading in VLLM]], which carries the 2026-10-19
verification against `vllm/config/cache.py` (no `swap_space`; `kv_offloading_size`
activates only with `kv_offloading_backend`):
- `VLLM Overview` (two argument tables), `VLLM Index` (parameter table),
`DFlash DSpark Speculative Decoding`, `Shared Memory Architecture Dankclaw`,
`Hybrid CPU GPU Processing Dankserver` -> cite the owner.
- `VLLM Index`'s example command used the dead flag; it now shows the live pair.
- `dankserver-vram-growth`: banner disambiguates the 2026-08 `frank-zealot`
(llama.cpp on the 3090 when that card was in Dankserver) from today's endpoint
of the same name on Dankclaw, so a retired crash signature does not read as a
live fault on a same-card-class service.
Refs: vault de-dup campaign, clusters C2 and C6.
The root and LLM index pages still advertised two retired endpoints' pages as
live procedure:
- `frank-medium-radiance-mxfp4`: frontmatter said "ACTIVE frank-medium
runbook" and the body opened "The ACTIVE frank-medium deployment". Both
re-keyed to the era it ran (2026-09-21 -> 10-10), with [[frank-archon]]'s
Eras table named as the owner of that shape.
- `frank-medium-model-swap`: read as live how-to for a retired endpoint.
Re-keyed to historical, pointing at [[Model Swap and Rollback]] for the
current swap law.
- Both index pages' rows re-keyed to match (rows are hand-copied from
frontmatter - no generator - so both sites move together).
Refs: vault de-dup campaign, cluster C5.
- `Running Qwen3.6 27B FP8 on Dankclaw`: had no banner at all and opened as a
live how-to. Added the RETIRED banner (iGPU endpoint today is
[[frank-dragoon]]), era-tagged the hardware table, and rewrote the
"primary service now runs vLLM-ROCm with Ling CIRU INT4" production note,
which was false by three fleet generations.
- `ling-3.0-flash-vllm-plan`: "frank-dragoon is the Qwen3.8-27B PP head" ->
past tense with the 2026-08-17 redo as the anchor and the live owner named.
- `frank-redo-2026-08-17` banner: stripped restated live shapes that had
already drifted (radiance 1.0.13, PrismML b10754 - the fleet runs 10777)
and the 3090-provenance clause owned by the PP deployment page; banner is
now locator-only (names + ports + links).
Verified before cutting: 457.9 t/s and the ping-RTT figures are solo-held by
Ethernet Bandwidth Math.md (no duplicate), so both scout-reported "duplicates"
were kept.
Refs: vault de-dup campaign, cluster C3.
One fact, one owner (de-dup campaign C4):
- `FastFlowLM NPU Inference.md` carried the host RAM figures three times
(29 GiB budget input kept as era data; the 2026-10-18 `free -g` re-reads
of 121/246 GiB cut -> [[Dankclaw System Specs]]/[[Dankserver System Specs]]).
- The 14-18 t/s PP-split decode is this page's own solo-held era measurement:
kept, tagged to the 2026-08 era with [[frank-redo-2026-08-17]].
- Promotion section: `frank-zealot` "now runs the NPU endpoint" era-tagged
(the name has been reused twice since; the NPU unit is `frank-npu`, masked).
- Repaired the Test Runbook splice introduced by the range edit above.
Refs: vault de-dup campaign, cluster C4.
Parent-written (scout summaries were corroborated against the files, not
trusted): every restatement of an endpoint shape now cites the endpoint page,
and four claims contradicted by today's fleet state are corrected.
- Quantization Production: the challenger-contract line quoted the retired
compaction-hub window; now cites [[frank-zealot]]'s Current shape. The 3090
worked example is era-tagged. Sources listed [[frank-dragoon]] three times.
- Quantization by Hardware Architecture: said the 3090 lives on Dankserver
(it moved to Dankclaw 2026-09-16; the Eras table owns the move) and ranked
the retired Ling-3.0 Quark INT4 service as the ACTIVE Dankclaw workload. The
active service is [[frank-dragoon]] on a closed `.hgn` format.
- GGUF Compatibility: Related claimed the ftype-115 ROCmFPX file was "in
production"; that is an era now, so it points at [[kingjones-q8agent-rocmfpx]].
- Model Swap and Rollback: dropped the fragment byte-count ledger (AGENTS.md
owns it), the Ampere FP8 hardware claim ([[Quantization by Hardware
Architecture]] owns it), halogen's HTTP surface ([[frank-dragoon]]'s wire
contract) and three brittle unit-file line refs (accepted loss: line numbers
are derivable, the root-unit law stays). Fixed the per-slot example, which
described the retired 3-slot arithmetic, and a Related bullet that called
[[frank-archon]] retired while linking the same page twice.
Census over the diff: 1 orphan line, the deleted unit line-number refs above.
The page claimed the unit had been failed since 2026-10-18 03:40 with a
hand-run process on the port. Verified live: both units are active under
their repo units (zealot since 21:06, the deployer's 2-slot reslice).
The dedupe passes kept trying to port single-owner experiment figures into
endpoint Eras tables. The law now states the test explicitly: two pages
carrying one figure is the defect; a figure with exactly one holder is fine
wherever it lives.
The 3-slot -> 2-slot reslice entry and the contended-rate entry carried the
full measurement ledger; the numbers belong to [[frank-zealot]] (shape block +
Eras rows), so the entry keeps the causal story (pool halving funds exact KV;
rates taken under a live consumer are floors) and links for the figures.
Enforces "one fact, one owner": technique pages keep mechanism, laws, traps
and verdicts; per-endpoint shapes, digests and era measurements are cited to
the endpoint page that owns them.
Also repairs the four pointers the pass left empty (each verified against the
cited owner before landing):
- EMULATION dequant doubles host RAM (118 GiB -> ~238 GiB) -> [[frank-archon]]
- 12.11 tok/s flat 304 -> 16,095 tokens -> [[Tcclaviger Fork Ops]]
- bundled-head acceptance ladder 0.739/0.554/0.427/0.340 -> [[Qwen3.8 PP Deployment]]
- llama.cpp PR #23897 dedup provenance -> [[qwen3.8-spec-matrix]]
Replaces the retired-shape illustration in Llama Cpp Serving Sizing with the
owned MiMo Q5_K_M row (6.40 GiB payload -> 20,530 MiB used), and unwraps a
hard-wrapped [[Dankserver System Specs]] link that failed link integrity.
Orphan census over the diff: 0 numbers lost (comma-insensitive match against
the whole vault plus AGENTS.md).
Conflicts and how each was resolved:
- AGENTS.md: kept the 2026-10-18 Model Swaps law (a payload change is the trio
edit, the deploy, /health and one probe; no bench unless Frank orders one).
The incoming Benchmarking-Policy wording it replaced is not lost: its one
durable fact -- on the PrismML fork build 10777 the per-request `timings`
object only reaches you on /v1/chat/completions, because a greedy ignore_eos
run on the raw /completion path answers 500 "Content-only format" -- is now
carried inside "When a bench IS ordered", step 2, and in [[frank-zealot]]'s
Contract section.
- frank-zealot-llama-prism-bonsai9-mtp.md: kept deleted. PR #22's additions
(the exact-KV boot ledger, the contended floor rates, the /completion
instrument trap) were ported into LLM/frank-zealot.md before the merge, which
is where the one-page-per-endpoint law says they live.
- log.md: auto-merged, then compressed to incident plus law -- the reslice
decision, the "a KV byte rate belongs to the payload it was measured on"
lesson that buried the invented 26,112 MiB figure, and the instrument law.
The boot ledger and the floor table now live only on [[frank-zealot]].
log.md drops 466 lines of per-swap ledger prose (1,169 lines out, 703 back) and
keeps the causal stories: why the host went down, why a knob resets at boot, why
a stale drop-in served a different model for 24 minutes, why the RAM was the
fault. Date sections are unchanged (65 before, 65 after) -- this is compression
inside sections, not deletion of days.
Every number the compression would have orphaned was ported to its owner first,
and a census of the deleted lines against the whole vault is what found them:
- frank-zealot: the two boots that priced non-KV (20,190/KV 12,800 => 7,390
without the projector; 22,972/KV 14,113 => 8,859 with it, so the vision tower
costs ~1.4 GiB for a 918 MB file), the 1,153 MiB-free 3-slot boot that made
the seam price non-KV at 9 GiB, and the f16 wall's own figures (fit 33,198 vs
23,558 free on a 25,088.00 MiB KV request).
- frank-archon: the pool-cut-is-headroom-not-arena law (2.75 GiB/rank bought
zero slots; d(reserve) = d(arena) = +248 slots; 14.87 GiB / 6,496 slots
against a 398,658-token pool), the MNBT compile-RANGE vs SEQS+1 law, and the
2 x 196,919 prefix-cache proof (393,837 tokens resident above the pool's
386,904; 94.1/102.3/117.6 t/s, acceptance 0.7284/0.7525).
- frank-dragoon: the KL arm of the 2026-10-17 A/B (mean 0.155 / median 0.057,
top-1 agreement 85.1 %) joins the PPL and NIAH figures already on the era row.
- Payload and Disk Reclaim: the 121 GB layer census lesson -- a huge image layer
can be the engine image, not a payload.
Also fixes a wikilink the rewrite had hard-wrapped across two lines, which the
link checker reads as a target that does not exist.
New seam: 'one page per live LLM endpoint'. The live set is derived from
scripts/ (an endpoint has both an env and a unit), and any <endpoint>-*.md page
in the vault fails it -- the enforcement of the one-page-per-endpoint law, so a
re-created era page cannot pass tests.
18 pages cut their per-endpoint shape, payload, port and window claims and
link [[frank-archon]] / [[frank-dragoon]] / [[frank-zealot]] instead. Several
of the cut claims had already drifted -- the stack page still described the
kingjones ROCmFP4 era on ROCmFPX, OpenWebUI carried the 27B/3-slot bench
narrative, Hermes named a retired max_model_len and the old service name, and
the OMP harness page's live-window block had drifted off frank-zealot's real
262,144 (it is now labelled a dated worked example whose authority is the
endpoint page).
Two facts gain a single owner instead of two: the up{job=~"frank-.*"} scope
lives in Infrastructure/Prometheus.md, and the blackbox/Anubis probe form in
Architecture/Stale Signal Audit.md, each cited from the other page.
Voice Packs and Frank TTS carried live-state claims that contradicted the
engine page's 2026-10-18 deploy record (nothing listening on 7772/8880, model
dir absent); they now point at the pages that own host state.
Every payload swap a live endpoint has ever run is now one row in that
endpoint's Eras table, with its rollback path in the same row. No per-attempt
page, no per-swap ledger, nowhere else.
- Added: LLM/frank-archon.md (radiance, 2x R9700 TP=2), LLM/frank-dragoon.md
(halogen flash-server, 19 eras), LLM/frank-zealot.md (llama.cpp PrismML fork
on the 3090). Each carries Current shape, Eras, Laws, wire contract, files.
- Deleted: 45 per-payload runbooks -- the frank-dragoon line (11 including the
pre-name halogen/kingjones/kairic/CIRU/eemin/agentionai/dflash2 pages and the
benchmark page), the frank-zealot line (llama.cpp/vLLM/ik_llama/SGLang eras),
the frank-archon line (vLLM/FreeToken/R9V/llama.cpp/radiance eras), plus
frank-medium, frank-npu and the ubatch/PCIe one-offs that owned nothing.
- Rewired: every wikilink and markdown-path reference to a folded page points
at the endpoint page instead; the three Research see-also lists and the
ubatch sweep's runbook link included. No anchored links existed in the set.
- Regenerated both index pages as pure locators -- one row, one clause, no
numbers -- so the docs_completeness locator law holds for all 153 pages.
tests/docs_completeness.bats green: index links every page, no unresolved
links. The pages moved to .AGENTTRASH/20261018-doc-dedupe/LLM/ (gitignored,
and git history holds every byte).
Every KEY=value is byte-identical to the previous revision (diffed against
git show HEAD: -- 28/25/20 keys). What left is the per-swap narration: the
archon header drops 207 lines of era prose, dragoon 127, zealot 104. Each
header now keeps the format lock, the digest/identity law, and one line per
knob explaining what a reader editing that line cannot recover anywhere.
The facts moved to their one owner: LLM/frank-archon.md, LLM/frank-dragoon.md,
LLM/frank-zealot.md. One stale claim dies with the trim -- the dragoon header
still said the KV pool is ONE 200,704-position budget behind 2 slots, which
contradicted its own 401,408 paragraph; the page carries the correct ledger.
Also kills two wrong arithmetic claims in the zealot header: the q8_0-V and
CTX_SIZE=401408 rungs each free 3,840 MiB at the 524,288 pool, not 8,192 and
not ~6.7 GiB.
The runbook index collapses to three live endpoints plus a retired-row
locator: a payload swap is an Eras row on the endpoint's vault page, never
a page of its own. Restated shapes, digests and measured numbers are
replaced with links to [[frank-archon]], [[frank-dragoon]], [[frank-zealot]]
and scripts/<svc>.env, which is the authority for every value.
Carries the frank-zealot instrument correction from d457985: on the PrismML
fork build 10777 the per-request timings object only reaches you on
/v1/chat/completions — the raw /completion path answers 500 on a greedy
ignore_eos run.
Deployed shape verified live: KV 16,384.00 MiB (ledger-exact 32,768 B/token x
524,288), RS 100.50 MiB (2 cells), settled nvidia-smi 21,176 used / 2,949 free,
n_slots 2, n_ctx_slot 262144, DropInPaths empty.
Rates recorded as FLOORS: another consumer drove the endpoint through the whole
window (56 prompts on the other slot in three minutes, 96% GPU-util). Single
stream 134.1-141.5 (145.8/163.2 in a lighter minute) vs the 168.7 q8_0 record;
two-stream aggregate 256.6-265.1 vs ~300 across three; prefill 3,159.5 at
106,282 beats the 2,877 record while contended; decode at 106k depth 71.5 (new
baseline, no prior). Per-stream concurrency improved: ~130 each vs 102.8.
Instrument correction: the PrismML fork build 10777 answers 500 'The model
produced output that does not match the expected Content-only format' for a
greedy ignore_eos run on the raw /completion path, so the per-request timings
must be read off /v1/chat/completions. AGENTS.md Benchmarking Policy corrected.
AGENTS.md: topology row, the frank-zealot runbook section (shape, KV
accounting, MTP verdict, compaction-hub concurrency), and two Key
Lessons bullets -- the draft-context bill restated with the real knob
names, plus a new lesson that a quant's scale overhead belongs in its
byte rate (the 6.25 % invented by doubling 13,056.00 MiB).
LLM/frank-zealot-llama-prism-bonsai9-mtp.md: retitled to the live shape;
the 3-slot q8_0 boot ledger and its measurements are relabelled as the
arm this replaced (byte-identical numbers, honest attribution), the
corrected f16 rate derivation is added, the MTP census is labelled
q8_0-era, the fallback rungs are re-derived for the 524,288 pool, and the
boot-ledger section carries an explicit PROJECTION that must be replaced
with the measured ledger from the first f16 boot.
Pointers re-pointed (live-shape sentences only; every historical ledger
left byte-identical): LLM/index.md, vault index.md, Dankclaw/OpenWebUI.md,
and the retired-runbook "today" pointers in mimo-q5km, mimo-q6k,
qwen35-9b-q6k, swift15, sglang-mimo-v26. log.md gains the dated entry.
Frank: "shift frank-zealot to 2 parallels of unquantized kv".
Shape: CTX_SIZE 786432 -> 524288, PARALLEL 3 -> 2, CACHE_TYPE_K/V
q8_0 -> f16. The per-slot window is UNCHANGED at 262,144 (the native
trained window, and models.yml contextWindow), so what the exact cache
buys is fidelity and what it pays is one concurrent conversation.
The POOL is the term that had to move: exact KV bills 32,768 B/token at
this geometry (8 full-attention layers x 4 KV heads x 256 dim, 2 B,
K and V), so the old 786,432 pool asks 24,576 MiB against the 24,123 MiB
the driver leaves addressable. 524,288 positions = 16,384 MiB, projected
~21,200 used / ~2,900 free from the 13:19 boot ledger (only the KV pool
and one recurrent-state cell move).
Also corrects a repo-wide arithmetic error: "f16 would bill 26,112 MiB"
doubled the q8_0 ledger figure INCLUDING its 1/32 scale overhead. q8_0 is
1.0625 B/element, f16 exactly 2.0, so the true ratio is 1.882x. The wall
stands; the number was wrong.
And it corrects a false claim about the drafter: this build DOES expose
--spec-draft-type-k / --spec-draft-type-v (verified against
llama-server --help), so the draft cache is quantizable -- the
exact-KV-plus-MTP exclusion is a measurement gap, not a law, and the
1,088 MiB quantized-draft arm is recorded as unmeasured.
Seam: tests/frank_zealot_unit.bats re-pins the shape contract (2 slots,
524,288 pool, 262,144 per slot) and adds a VRAM fit gate that computes
dtype x pool + the measured non-KV ledger terms against 24,123 MiB with
>=1 GiB of slack -- and proves the gate REFUSES exact KV at the old
pool, so passing means something. 18/18 green.
A consumer that lists the 29-name af_*/am_*/bf_*/bm_* pack and refuses
narrator1 without a request reaching the façade is synthesizing in the
browser. Frank TTS's troubleshooting table gains that symptom; OpenWebUI's
See Also gains the service page.
The `Voice "narrator1" not found. Should be one of: af_heart, …, bm_fable`
toast is OpenWebUI 0.11.4's bundled Kokoro.js validator: the enum is
Kokoro's fixed 29-voice pack, not frank-tts's registry. Frank-tts answers
200 audio/mpeg for the exact OpenWebUI body from inside the container, and
the failing click leaves no `audio/speech` line in the container log — that
contrast is the instrument. Admin config DB is correct
(engine=openai, base URL :7772/v1, model frank-tts, voice narrator1); no
container env and no user row carries an audio override, so the
`browser-kokoro` choice is per-browser storage. Fix: Settings → Audio →
TTS Engine → Default.
#18 shipped DEFAULT_VOICE and #17 explained why the harness cannot name a voice from a setting, but the endpoint page still described voice resolution as if every request carried a name. One contract row, one paragraph in Naming a voice (a call that names nothing is the harness default shape, not a client bug), the env-file row now lists the default voice among its knobs, and the worklog records the rollout: both merges, the one-dispatch deploy with the 33/33 gate, the card flipping to up on the next checker tick, and the previously failing call returning 430124 bytes with the journal reading "speech voice=narrator1 (default)".
The omp speech device forwards a voice only when the call passes voice_id - there is no setting that feeds one (speech.voice is the TUI speak-aloud Kokoro enum, tts.localVoice belongs to the local backend) - so every harness voiceover arrives with no voice key and the shim answered 400 "unknown voice None". Measured on the deployed endpoint 2026-10-18, from a restarted session with the speech role pointed here.
DEFAULT_VOICE makes the absent case a spoken operator choice instead of a code surprise: the pack named there answers a voice-less request and the journal line is marked (default), so an unrequested voice never reads later like a requested one. An UNKNOWN name still 400s with the list that exists - the no-silent-fallback law that this endpoint was built on is untouched, and the strict shape stays the code default (empty DEFAULT_VOICE), which the second new seam test pins. Ships narrator1 in scripts/frank-tts.env.
The section this PR adds claimed speech.voice was the forwarded voice key. It is not: the openai-speech transport builds {model, input, response_format, voice?} and sets voice only when the CALL passed voice_id, tts.localVoice is the local Kokoro backend key, and speech.voice is the TUI speak-aloud voice whose enum is Kokoro-only. A restarted session proved it the honest way - the tts device arrived at 7772 with no voice key and got "unknown voice None; available voices: narrator1, narrator2, narrator3" - so the fresh-process success earlier that day was the run that was told to pass voice_id, not evidence of a setting.
The law now reads: voice comes from voice_id alone, so a voice-less call is the harness default shape (the endpoint grew DEFAULT_VOICE in its own PR to answer it), and speech.voice must stay a Kokoro name - a pack name there breaks the vocalizer and does nothing for synthesis. config.yml reverted to am_fenrir on the operator box, and the worklog entry that recorded narrator1 as wiring carries the correction.
Six statements still claimed the endpoint did not exist: both vault descriptions, the two index.md bullets, the Open Notebook blocker, and the engine page deploy order that opened with "Nothing below has been run". All re-keyed to the deployed state, with the ledger the deploys printed: engine healthy 31 s after the build at 4.0 GiB resident, three voices warm, the ladder measured at 1883 ms then 1226-1283 ms (not the retired 6.2-8.0 s), and two voices rendering the same sentence into different bytes.
Adds a The harness speech role section to the endpoint page with the three wiring laws measured the day of the flip: speech.voice is the forwarded key (tts.localVoice belongs to Kokoro), an explicit speech role has no fallback candidate, and models.yml loads at process start while config.yml is read live - so a session older than the frank-tts row fails exactly like a dead endpoint. Fresh-process proof: omp -p --tools tts saved 180524 bytes with voice=narrator1 while the facade logged the matching speech line.
The endpoint deployed on 2026-10-18, so the admin card must carry its state rather than a placeholder: wip becomes false (the registry schema wants the key on every service; false is what the live cards use) and the description drops the "Hidden until deployed" line. showLink stays false and the card stays admin-only: an HTTP API has no page to open, and homepage-status.py counts ANY HTTP response as up, so the card is honest with no link and no /health path in the probe. The voice seam that pinned wip == down is re-keyed to the deployed contract and now also refuses a link or a user-category placement.
PR #15 corrected the engine's page, the research brief's law bullet and the
warmer's header, but the retired ladder had six more homes: both quadlet files
("6.2-8.0 s… 0.5 ms", "~6-8 s each", "paying the 5.5 s cold decode"),
`scripts/frank-tts-engine.env` (the whole law paragraph and the WARM_TEXT note),
`scripts/frank-tts.env`, the [[Frank TTS]] troubleshooting row that told an
operator to expect ~5.5 s, the research brief's consequences paragraph, and one
test comment.
All re-keyed to what was measured on the deployed container 2026-10-18: the
first synthesis of a freshly registered voice costs 1,883 ms against 1,226-1,283
ms afterwards, so the warm pass buys about 0.6 s — and the reason it exists at
all, which the pages never led with, is that the registry is memory-only: without
the hook every voice name is a 400 until somebody synthesizes. The trial's dated
worklog entry stays as written; the correction points at it.
No behavior change: comments, unit lore and docs only, so no cfhash move and no
image rebuild.
The first real boot gave the chance to measure what warming actually buys, and
the trial's numbers were wrong:
- Empty registry, register only, then time: the FIRST synthesis of a freshly
registered voice cost 1 883 ms; the next two 1 283 and 1 226 ms. A ~110-word
request cost 9.4 s and then 9.8 s.
- So the published 6.2–8.0 s cold / first-audio-byte 5 530 ms / later-requests
0.5 ms ladder does not reproduce, and request time tracks the audio generated
rather than snapshot state. The 0.5 ms figure is an internal state-restore
cost, never an end-to-end request time.
- Warming still earns its place for a different reason than the page claimed:
the registry is memory-only and re-registering does not warm, so a boot without
the hook leaves the façade answering 400 for every name. The saving is ~0.6 s.
Law 1 of [[Frank TTS Engine]] is re-titled the registry law and carries the
measured numbers; its example journal line is a real one from today; the
troubleshooting row for "first request of the day is slow" — the retired law's
symptom — is replaced by the argument-cap fault that actually happened. The same
ladder in the research brief is annotated rather than erased, and [[Voice Packs]]
step 6 stops claiming the hook registers only packs it has not seen (the engine
replaces a name it already holds).
The deploy of the newly built engine image came back `0 voice(s) warm` with
`/app/warm-voices.sh: line 152: /usr/bin/curl: Argument list too long` above
every line, and the unit was `active` with a green `/health` beside it. The hook
built `{"name":…,"wav_b64":…}` in a shell variable and passed it as ONE curl
argument; the kernel caps a single argument at MAX_ARG_STRLEN = 131,072 bytes,
and the shipped reference clips base64 to about 1.27 MB, so curl was never
executed and the hook reported the absence as "HTTP no response".
The body now goes to a mktemp file and curl reads `@file`; the audio enters
through a redirection instead of a command substitution, so the script never
holds a 1.3 MB string at all. Every pack in the fleet is ~1 MB, i.e. ~13x the
cliff, so this was always going to happen — it took the first real packs to show
it because no fixture was ever that big.
The regression test therefore ships a 300 KB pack against the seam's REAL stub
listener with the REAL curl: it fails with the same kernel error the host did
(`Argument list too long`, then the 400-for-every-name summary), and passes once
the body is a file. Proved red before the fix, green after.
The vault link check reads [[Frank TTS Engine]] literally, so wrapping it made
the target "Frank\nTTS Engine" and the unresolved-link seam failed. Rewrapped,
not reworded.
Three faults, all of the reporting-success-while-doing-nothing species: the
build that could not start (no adduser in the base), the warm pass that could
not warm (podman's --env-file keeps the quotes), and the image that would never
rebuild (cfhash over the Containerfile alone). Records the proofs, the two
behaviour seams, and what stays undone: nothing is deployed, #2 phase 1 stays
open until 7772 answers.
Three pages, all from faults found this session:
- `LLM/deployment.md` gains "Locally built images": which images have no
registry digest, what the `cfhash` skip is for, why it must cover every COPY'd
file (the silent shape: build skipped, /health green, old code serving), and
the base-image half of the law — the engine's `useradd`, because the pinned
ubuntu:24.04 has no `adduser`.
- `Dankclaw/Frank TTS Engine.md` troubleshooting gains the two lines an operator
actually needs: every voice cold on a fresh boot with a hand-run hook
succeeding (= `WARM_TEXT` arrived quoted, `podman exec … env | grep WARM_TEXT`
proves it), and a 502 from the façade beside a 200 `/health` from the engine
(model loaded ≠ voices registered). The stale-warmer row is re-keyed to the
new rebuild rule.
- `Dankclaw/Voice Packs.md` step 4 was wrong: it told the next agent to restart
the engine *instead of* the warm hook because the hook "only registers packs it
has not seen". It registers every pack on every run and the engine replaces a
name it already holds — `tools/tts-server.cpp:148` at the pinned commit:
"Re-registering a name replaces the previous entry." A restart is what an
engine bump needs (the registry is memory-only), never worse, only slower.
- `Dankclaw/Frank TTS.md` deploy command re-keyed to `./scripts/deploy.sh
dankclaw --service frank-tts`, the way it is actually deployed; the raw
`deploy-remote.sh` form is plumbing.
build_compose_image skips `podman build` when the installed image's `cfhash`
label matches a hash taken from the checkout now — the skip is what keeps
frank-tts-engine's ten-minute recursive source clone off every re-deploy. The
hash covered the Containerfile ALONE, so any change to a file the image COPYs
was invisible: edit dankclaw/voiceservice/app.py or tts-engine-warm.sh, re-run
the deploy, and podman reports the existing image is fine. The redeployed
container runs the previous code, /health answers 200, and the e2e gate has no
way to see it. It bit the boot warmer twice this week.
Each case arm now names its own extra=(…) — the paths its Containerfile COPYs —
and the label hashes the Containerfile plus those. The two arms are kept apart
deliberately: the façade bakes app.py and tts-start.sh, the engine bakes
tts-engine-start.sh and tts-engine-warm.sh, and comfyui/loki/alloy have no extra
files, so their hash is unchanged and they do not rebuild on this commit.
Hashing rather than word-splitting because these are repo-relative paths under
a fixed checkout root.
The seam drives the real function against a stub podman and asserts observable
output — did the deploy ask podman to build, or did it skip? — in five steps:
first deploy builds, unchanged tree does not, editing the engine's COPY'd warm
hook rebuilds the ENGINE image and not the façade, editing app.py rebuilds the
façade. Reverted scripts/deploy-remote.sh to prove it red on the old hash: the
warm-hook step is exactly where it fails.
`WARM_TEXT` reaches the container **with its quotes**. The unit reads the env
file through podman's `--env-file`, which takes the literal remainder of the
line — no shell parsing — while bash `source` strips one layer. The hook
interpolated the raw value into the synthesis body, so with the quotes intact
the JSON was invalid and every voice logged "registered, but the warm synthesis
failed" while a hand-run hook (bash shape) succeeded. Reproduced both ways
against a real `podman run --env-file`, not by reading the man page.
The hook now strips exactly one matched pair — only when both ends are the same
quote AND the inner content carries another quote or a backslash, so a value
that legitimately contains quotes is never mangled — and then JSON-escapes what
is left, which is what makes a sentence like `He said "hi\" once"` arrive as
itself instead of corrupting the body.
The seam gains a test that reads `scripts/frank-tts-engine.env` BOTH ways —
`source` and the way `podman --env-file` does — and asserts the engine receives
the same sentence either way, in both shapes. A test that only sources the file
cannot see this bug, which is how it survived the first deploy.
The runtime stage of Containerfile.engine ran `adduser --system …`. The pinned
ubuntu:24.04 base ships no adduser, so the build died at that RUN line with
crun's "executable file not found in $PATH".
Verified in the base itself on Dankclaw, 2026-10-18: `command -v adduser`
prints nothing, and the exact replacement line — `useradd --system --uid 10000
--home-dir /app --shell /usr/sbin/nologin tts` — answers `uid=10000(tts)
gid=999(tts)`. Same account as before: numeric uid/gid 10000, non-login shell,
no password entry. Its one warning ("uid 10000 is greater than SYS_UID_MAX
999") is cosmetic and is what keeps the uid equal to the façade's, so the
comment says so before someone "fixes" it by dropping --system.