# gen-seat mixed NVFP4+FP8 requant + char-rp tool-parser fix (2026-08-15, overnight)

Autonomous overnight session. Two operator-queued items, both closed.

## 1. char-rp / MeroMero tool-call parser (parked since the prior session)

**Symptom:** every tools-bearing request to `char-rp` (:8016) returned
`400 "auto" tool choice requires --enable-auto-tool-choice and --tool-call-parser`.
The seat had **no tool parser configured at all** — the migration from the
Magidonia GGUF seat dropped it.

**Fix.** MeroMero-v2 is Gemma-4 and emits its own native
`<|tool_call>call:name{...}<tool_call|>` syntax, not the qwen3_coder XML the
Qwen-family seats use. vLLM 0.24 ships a `gemma4` tool parser whose
TOOL_CALL_START/END + CHANNEL_START/END + escape constants match this
tokenizer's `etc_token`/`eoc_token`/`escape_token` exactly (verified before
deploying, not assumed).

Four flags, and they are a **set**:

```
--tool-call-parser gemma4
--enable-auto-tool-choice
--reasoning-parser gemma4
--default-chat-template-kwargs '{"enable_thinking": false}'
```

- Without the **reasoning parser**, the post-tool-response turn leaks a literal
  `<|channel>thought\n<channel|>` prefix into `content` (upstream vllm #45834 —
  the chat template leaves the prompt inside an open channel block).
- The **`enable_thinking: false`** is mandatory, not cosmetic. The parser reads
  it from `chat_template_kwargs` and **defaults it to `True`**
  (`vllm/parser/gemma4.py:439`). True → `is_reasoning_end()` returns False at a
  new turn → engine pre-initialises to REASONING → **all plain RP prose lands in
  `reasoning_content` and `content` comes back null**, breaking every char-rp
  consumer. Caught by reading the parser before deploying it.
- **Zero behavioural risk, proven not asserted:** `chat_template.jinja:350`
  already defaults `enable_thinking` to false, so passing it explicitly renders a
  **byte-identical prompt** — diffed across plain / with-tools / post-tool-response
  / system-prompt shapes before the flag went anywhere near the live seat.

Verified green: tool call (streaming + non-streaming), tool-result round-trip
(leak gone), plain prose in `content` with `reasoning` null, vision. Commit
`b8f0f4c`.

## 2. gen seat requant — the "W4A8" framing was wrong

**The queued task was not servable as specified.** vLLM 0.24's compressed-tensors
dispatcher (`compressed_tensors.py:704-713`) accepts NVFP4 weights with exactly
two activation options — `None` (W4A16, which **forces the Marlin kernel**,
`kernels/linear/__init__.py:881-883`) or NVFP4 (W4A4). Anything else, FP8
included, raises `ValueError: For NVFP4 weights, input quantization must also be
NVFP4 format`. `CompressedTensorsW4A8Fp8` exists but is **INT4** weights
(`W4A8_SUPPORTED_TYPES_MAP = {4: int4}`) gated on `_check_scheme_supported(90,
match_exact=True)` — Hopper only, so on Blackwell it is closed twice over.

The ~20% intuition was right; the *scheme name* was wrong. FP8 has to enter
**per-layer-group**, not as activations on NVFP4 weights.

**Two baseline corrections.** The handoff's "~68 tok/s, ~42% acceptance" did not
reproduce. Cache-busted (unique prompt per run — with a fixed prompt, prefix
caching returns byte-identical timings and you measure nothing), the incumbent
W4A16 build already did **80.12 tok/s at 47.8% acceptance** — i.e. essentially
*at* the handoff's stated W4A8 target of ~82. Had that not been re-measured the
whole chase would have been declared a success for doing nothing.

**The shortcut that saved hours.** `unsloth/Qwen3.8-27B-NVFP4` was already on-box
(pulled the previous day) — same architecture, same size, a published
mixed-precision scheme. Serving it as a probe measured **+19.1% at identical MTP
acceptance** — proving the gain was real and kernel-level *before* committing to
a requant. Its config was then read out as the reference recipe.

**The recipe** (byte-for-byte unsloth's, applied to the abliterated weights):

| group | scheme | targets |
|---|---|---|
| `group_0` | FP8 W8A8, channel weights + per-token dynamic acts | `self_attn.{q,k,v,o}_proj`, `linear_attn.{in_proj_qkv,in_proj_z,out_proj}`, `lm_head`, **layers 56-63** MLPs |
| `group_1` | NVFP4 W4A4, tensor_group gsize16, fp8 scales, `imatrix_mse` weights, `dynamic:"local"` acts | **layers 0-55** MLP `{gate,up,down}_proj` |
| kv | FP8 static tensor | — |
| ignore | vision tower, `linear_attn.{norm,in_proj_a,in_proj_b}`, `re:^mtp.*` | — |

Holding the **last 8 layers' MLPs at FP8** is the accuracy trick. Targets were
made explicitly non-overlapping (group_1 enumerates 0-55) rather than trusting
group precedence, and `validate_targets.py` proved coverage against real module
names — 0 overlap, MLP union = layers 0-63 — before any GPU time was spent.

**Results (cache-busted, bs=1):**

| metric | W4A16 | mixed | delta |
|---|---|---|---|
| decode tok/s | 80.12 | **94.53** | **+18.0%** |
| prefill tok/s (~6.7k prompt) | 3,206 | **6,334** | **+98%** |
| prefill tok/s (~27k prompt) | 2,862 | **5,085** | **+78%** |
| TTFT on a ~27k doc | 9.43 s | **5.31 s** | −44% |
| MTP acceptance | 47.8% | 47.7% | unchanged |
| perplexity (6 passages) | 6.941 | 7.059 | +1.7% worse |
| abliteration compliance | 4/4 | 4/4 | preserved |
| weights on disk | 27.7 GB | 22.5 GB | −19% |

Surface test 6/6 on the live seat (plain chat, vision, tool calling, thinking
split, 36K-token needle retrieval, streaming); all 7 LiteLLM aliases verified
routing. Commit `74f596b`.

## Foot-guns banked

- **`llm-compressor` PRUNES `ignore` entries that matched no module at quant
  time.** The wrapper class never loads the MTP head, so `re:^mtp.*` matched
  nothing and was silently dropped from the saved config — the exact bug that
  cost two prior rounds (vLLM then loads the grafted BF16 MTP as quantized →
  uninitialised → 0% acceptance). `post_quant.py` now **re-injects it after the
  graft and re-verifies**. That check *fired on this run* — it was not
  hypothetical.
- **Prefix caching silently fakes prefill numbers too.** The prefill harness originally used a
  *seeded* nonce, so run 2 regenerated run 1's prompts verbatim and read **~41k tok/s of
  cache-hit** instead of ~5k of real prefill. Same class of error as the decode bench. Use
  `SystemRandom`; never seed a cache-busting nonce.
- **vLLM's `prompt_logprobs` are garbage while speculative decoding is on** —
  ~uniform over the vocab (median rank ~10⁵, logprob ≈ log(1/vocab); " Paris"
  after "The capital of France is" ranked 69698). Perplexity must be measured on
  a seat served **without** `--speculative-config`. The harness now raises rather
  than reporting the garbage.
- **`gen-seat/.env` is mode 0600 / lkraven-owned** → *every* `docker compose`
  call needs `sudo`. Without it compose fails `permission denied` reading `.env`,
  **leaves the old container running**, and the change silently does not take —
  which produced one round of "benchmark results" that were just the unchanged
  baseline. Hard-verify against `docker inspect` argv after any such change.
- **GPU0 co-residency is a zero-sum budget.** The smaller mixed weights meant gen
  at the old util 0.45 absorbed the slack as KV (17.0 GiB / 477K tokens) and left
  meromero **0.18 GiB** short of its 0.52 → crash-loop. Fixed at
  `GEN_GPU_MEM_UTIL=0.43` (15.1 GiB / 422K tokens, still 1.6× the 262K context).
  Both seats now 94.4/97.9 GB.

## Measured negatives — do not re-chase

- **`GEN_SPEC_TOKENS` is already optimal at 3.** Swept on the live seat:
  n=2 → 77.1, **n=3 → 80.1**, n=4 → 78.7, n=5 → 75.9 tok/s. Higher n trades
  acceptance for draft width and loses.
- **W4A4-everywhere was never attempted** and should not be — the accuracy-safe
  shape is precisely the mixed one (FP8 on attention + late MLPs).

## Artifacts

- Pipeline + acceptance harness + raw JSON: `services/gen-seat-mixed-quant/`
- Stack docs: `stacks/gen-seat/README.md`, `stacks/meromero-charrp/README.md`
- Rollback: `sudo cp /opt/docker/compose/gen-seat/.env.bak-w4a16-20260815 …/.env`
  then `sudo docker compose up -d vllm-gen`; old build untouched at
  `/tank/aimodels/qwen38-27b-uncensored-nvfp4`.
