Files
esh-pfi-infrastructure/stacks/erp-seat/compose.yaml
T
vh 9a916a759f Swap the MeroMero A4B onto the erp-seat seat as char-rp-fast, retire the Pfish-6 alias
Operator: "replace that a4b moe over pfish-6 -- remove the pfish-6 alias and
create an alias for char-rp-fast."

G4-MeroMero-26B-A4B-it-uncensored-heretic-NVFP4A16 is live on ana-ml2 :8021 under
its own served name, behind the new gateway alias char-rp-fast. Pfish-6 is gone
from the gateway and now returns an explicit 400 rather than a substitution; 0 of
17 LiteLLM keys scoped it, so nothing was orphaned. The compose project name stays
erp-seat because asset-engine derives seat liveness from it.

The first quant of that A4B served NaN and passed its healthcheck doing it. It was
built with the dense v2-31B recipe, whose ignore list has no router regex, so all
30 MoE routers were quantized to 4 bits -- and a 4-bit router changes which experts
run rather than degrading them. Quant rc=0, healthcheck green, correct KV pool,
correct served name, and every completion returned finish_reason=length with the
full token count and content: null. The model was emitting a full budget of tokens
that decoded to the empty string. Raw /v1/completions was empty too, ruling out the
chat template and the reasoning parser. The signal that named it was logprobs:
vLLM refused to serialize the response, "Out of range float values are not JSON
compliant: nan".

The lesson is about the control rather than the router. That tree had already been
structurally diffed and passed -- against a verified-good DENSE quant of the same
Gemma-4 family. A dense model has no routers, so the single thing that was wrong
was the single thing the control could not distinguish. Diffing instead against
Pfish-6, a known-good quant of the same 26B-A4B MoE, gave it in one line: 222
ignore entries against 252, the 30 missing being layers.N.router.proj. A positive
control is only worth what it can distinguish, and "same family" is not "same
architecture class".

Re-quantized with the MoE recipe, whose dry-run asserts 11,520 expert Linears and
refuses a router in the quantize set before any GPU time. The live seat then passed
prose with no channel-prefix leak, a solid-colour image read correctly, an auto
tool call parsed, finite logprobs, and KV 534,649 tokens / 2.04x carried over from
Pfish-6 unchanged. The broken tree is parked on ana-ml2 as
...-NVFP4A16.BROKEN-routers-quantized-20260910.

Section 4.4's temp port was not reachable: 15.9 GiB of weights plus KV plus
multimodal encoder-cache profiling does not fit in the ~19 GiB free beside GPU1's
six other tenants -- 0.20 utilization refused admission, 0.185 OOM'd in encoder
profiling. The substitute was reversibility and ordering: named .env backup, prove
the seat on its real port while no alias points at it, move the alias last. That is
why a NaN-serving seat never reached a consumer. The seat was down about 16 minutes
across two attempts; no consumer saw a broken alias.

Playbook gains the router-quant failure signature and the control-class rule in
3.15, and a logprobs check in 4.4. seat_verify.py carries that check as check 6.

Quality is NOT established: no RP eval, no long-context check, no A/B against
Pfish-6 or char-rp. Samplers are the author's card values, untuned here.
2026-09-10 11:34:05 -07:00

161 lines
8.7 KiB
YAML

# erp-seat — the RP seat on ana-ml2 GPU1. Serves the **MeroMero A4B MoE** NVFP4A16 quant
# (G4-MeroMero-26B-A4B-it-uncensored-heretic) behind the gateway alias `char-rp-fast`.
#
# ⚠ THE STACK NAME IS HISTORICAL. It served Pfish-6 (the run-6 ERP-tune LoRA merge) until
# 2026-09-10, when the operator swapped the occupant: "replace that a4b moe over pfish-6 --
# remove the pfish-6 alias and create an alias for char-rp-fast." The compose PROJECT name is
# deliberately NOT renamed: asset-engine derives seat liveness from it, so a rename reads as
# OFFLINE. Pfish-6 remains on disk at /tank/aimodels/erp-tune-v6-nvfp4a16 and the pre-swap host
# env is at /opt/docker/compose/erp-seat/.env.pfish6.bak-20260910 -- one cp plus `up -d` back.
#
# ⚠ THE FIRST A4B QUANT SERVED NaN AND LOOKED HEALTHY DOING IT. It was built with the DENSE
# recipe, whose ignore list carries no router regex, so all 30 MoE routers were quantized to
# 4 bits and expert selection was destroyed (playbook §3.15). The seat passed its healthcheck,
# returned finish_reason=length with the full token count, and every response decoded to the
# empty string; the give-away was NaN logprobs. Re-quantized with
# services/erp-seat-quant/quant_nvfp4a16_gemma4_moe.py, whose target guard refuses exactly
# that. Use the MoE recipe for anything in this family; the dense one is for the v2-31B.
#
# PFISH-6 PROVENANCE, kept because it is still the rollback target. Run 7 was retired by
# operator ruling 2026-09-09 ("we're gonna stay on 6 for now"): its gate failure turned out to
# be a DETECTOR BUG (the adjective "minor" in a HARD rule, fixed cc42d76 in brokkr-smithy), but
# run 7 was independently a poor run (primary FLAT +2, both diversity families reduced,
# long-context coherence 1.0 -> 0.875). Run 6 was the standing seat here until the 2026-09-10
# swap above. Routing aliases live in the gateway, not here.
#
# Serve recipe copied from stacks/gemma4-charrp (same architecture + quant format, proven on this
# box): gemma4 tool + reasoning parsers, enable_thinking pinned false, model's own stock template.
# It carries over to MeroMero A4B unchanged -- verified 2026-09-10 end to end: clean prose with
# no channel-prefix leak, a solid-colour image read correctly (vision towers intact), and an
# auto tool_choice call parsed.
# GPU1 is SHARED (scriberr, embed, rerank, coder, reward, charrp live there): the live .env runs
# ERP_GPU_MEM_UTIL=0.30, and the KV pool is pinned in bytes below regardless, so the ratio only
# has to clear admission.
# Quant pipeline: services/erp-seat-quant/ (MoE recipe -- NOT services/meromero-quant/, which is
# the dense one). Tunables in .env.
name: erp-seat
services:
vllm-erp-seat:
image: ${ERP_IMAGE:-vllm/vllm-openai:nightly-311b3513af33bc29b4acb2fde2e9313e5e9966a0}
container_name: ${ERP_CONTAINER:-vllm-erp-seat}
restart: unless-stopped
ipc: host
ports:
- "${ERP_PORT:-8021}:8000"
volumes:
- /tank/aimodels:/tank/aimodels
environment:
- VLLM_API_KEY=${API_KEY:-}
command:
- ${ERP_MODEL:-/tank/aimodels/G4-MeroMero-26B-A4B-it-uncensored-heretic-NVFP4A16}
- --quantization
- compressed-tensors
- --served-model-name
- ${ERP_SERVED_NAME:-G4-MeroMero-26B-A4B-it-uncensored-heretic-NVFP4A16}
- --tool-call-parser
- gemma4
- --enable-auto-tool-choice
# --reasoning-parser gemma4 + the enable_thinking pin are BOTH required (measured 2026-09-08):
# without them the post-tool-response turn leaks a literal "<|channel>thought\n<channel|>"
# prefix into content (3/3), exactly as stacks/gemma4-charrp/README.md warns. They do NOT
# fix forced (named) tool_choice — that failure is independent of the reasoning parser
# (measured 1/9 with it, 3/9 without): the model free-writes Gemma native call syntax
# instead of grammar-constrained JSON. See README for the forced-tool_choice status.
- --reasoning-parser
- gemma4
- --default-chat-template-kwargs
- '{"enable_thinking": false}'
# tool_choice:"none" trap (measured 2026-09-08): without this flag vLLM still renders the
# tools into the prompt, the model emits a tool call anyway, and because tool parsing is
# off for tool_choice=none the reply comes back with content=null AND tool_calls=null —
# an empty turn. The flag drops the tools from the prompt so the model answers in prose.
- --exclude-tools-when-tool-choice-none
- --chat-template
- ${ERP_CHAT_TEMPLATE:-/tank/aimodels/G4-MeroMero-26B-A4B-it-uncensored-heretic-NVFP4A16/chat_template.jinja}
- --max-model-len
- "${ERP_MAX_MODEL_LEN:-262144}"
# KV pool pinned in BYTES, not inferred from the utilization ratio. GPU1 is
# SHARED with seven other containers (charrp, scriberr, embed, rerank, coder,
# reward), so a ratio silently yields a different cache depending on what else
# is resident at start time; an explicit figure is reproducible and is what the
# concurrency ceiling is actually computed from.
#
# ⚠ KV-per-token is NOT a constant you may carry between context settings.
# Measured on THIS build (2026-09-09): 63.2 KiB/token at max-model-len 32768,
# 16.6 KiB/token at 262144 — a 3.8x difference, because Gemma-4 is hybrid
# attention (sliding_window 1024 on most layers) and the sliding layers stop
# scaling with the context while the full-attention layers keep going.
# Extrapolating the 32k figure to 256k under-counted the achievable cache by
# 3.8x and would have sized this seat wrong. Re-measure per configuration.
#
# 8.49 GiB -> 534,649 tokens -> 2.04x a full 262,144-token context, which is
# the operator's sizing rule (KV = 2x max context, 2026-09-09).
#
# This figure SURVIVED the 2026-09-10 Pfish-6 -> MeroMero-A4B swap unchanged, and that
# is not luck: the two are the same architecture field for field (30 layers, kv 8,
# head_dim 256, sliding_window 1024, 25 sliding / 5 full, 128 experts top-8), so the
# KV-per-token is the same number. Confirmed by reading 534,649 tokens / 2.04x back out
# of the new engine's log rather than assuming the pinning carried.
- --kv-cache-memory
- "${ERP_KV_CACHE_MEMORY:-9114000000}"
- --max-num-seqs
- "${ERP_MAX_NUM_SEQS:-32}"
- --gpu-memory-utilization
- "${ERP_GPU_MEM_UTIL:-0.35}"
- --kv-cache-dtype
- fp8
# SPEED (2026-09-09). ⚠ NVFP4A16 is WEIGHT-ONLY: activations stay 16-bit, so
# there is no FP4 GEMM to issue and the FlashInfer/CUTLASS NVFP4 MoE kernels
# do not apply — vLLM's "your GPU does not have native support for FP4"
# warning is about the SCHEME, not the card. ana-ml2 is sm_120 and DOES have
# native FP4; a W4A4 build would use it, at a fidelity cost the operator
# already settled against for this seat. Marlin is the correct kernel here.
# PROVEN, not assumed: forcing moe_backend=flashinfer_cutedsl fails at engine
# init — "does not support the deployment configuration since kernel does not
# support current device cuda". Marlin is the only viable NVFP4 MoE kernel
# here, so `auto` is already optimal and this knob exists to document that
# rather than to be turned.
#
# THE REAL LEVER WAS CONCURRENCY. max-num-seqs was 8, which capped the seat
# far below its KV pool (534,649 tokens). Measured on this build 2026-09-09,
# 3 rounds each, 300-token generations:
# n=1 219 tok/s (unchanged by the seq limit)
# n=4 676 tok/s aggregate
# n=8 1269 tok/s aggregate <- was NOT REACHABLE at max-num-seqs 8
# n=16 2170 tok/s aggregate <- 3.2x the old ceiling
# Single-stream latency is identical, so the raise costs nothing and lifts
# the ceiling ~3.2x. CUDA-graph capture sizes followed automatically to 64.
- --kernel-config
- '{"moe_backend": "${ERP_MOE_BACKEND:-auto}"}'
- --trust-remote-code
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids:
- "${ERP_GPU_ID:-1}"
capabilities:
- gpu
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 600s
networks:
- tnet
labels:
- homepage.group=AI - Inference
- homepage.name=char-rp-fast (MeroMero 26B-A4B, NVFP4A16 MoE)
- homepage.icon=mdi-fire
- homepage.description=MeroMero A4B abliterated RP seat, NVFP4A16 weight-only, vision intact (ana-ml2 GPU1)
- homepage.href=http://10.250.50.54:${ERP_PORT:-8021}/docs
networks:
tnet:
name: traefik-net
external: true