Files
club-3090/docs/COMPOSE_GENERATOR.md
T
noonghunnaandClaude Opus 4.8 a9ffb532ef refactor(vllm): reconcile vLLM engines to v0.22.0 — two-engine split (#254)
Replace the purged `vllm-nightly-clean` pin (nightly-bf610c2f, now 404 on
Docker Hub — so the launcher injected a dead image for every slug on it)
with a v0.22.0 two-engine design:

- `vllm-stable`: OVERLAY-FREE v0.22.0 — the broad successor to
  vllm-nightly-clean. Serves Qwen3-Next (hybrid 27b + MoE 35B-A3B), generic
  `dense` transformers, and uncurated derived-emission bases. Migrates
  vllm/qwen-35b-a3b-dual, vllm/dual, vllm/minimal (live-validated on stock
  v0.22.0: TP=2 + MTP + tool-call clean; marlin-pad confirmed unnecessary).
  Added as the first loads:true arch pin on the qwen + dense rows.
- `vllm-gemma-stable`: KEPT as the OVERLAY-CARRYING v0.22.0 engine (#40391
  per-head INT8 KV + #42006 tool-parser) — the Gemma 4 path. Unchanged.
- `vllm-pip-baseline`: the renamed pip `dense` lower-bound (frees the
  `vllm-stable` name for the docker engine above).

Why two engines and not one: `vendored_overlays` is LOAD-BEARING, not
documentation. `derived_emittable` (CONTRACT-5) refuses any engine whose
`vendored_overlays != []` as a derived-emission base, and `diagnose-profile`
expects the Gemma overlay declared on its engine. A single engine cannot be
both overlay-free (for derived/Qwen bases) and overlay-carrying (for Gemma
provenance) — so the two stay split. Compose owns patch APPLICATION; engine
owns provenance/compatibility. (An attempt to fold both into one engine broke
4 tests via exactly these two consumers; the split is the validated shape.)

Also adds scripts/tests/test-compose-image-drift.sh: asserts every fixed
`${VLLM_IMAGE:-<tag>}` compose default equals its engine's `install.spec`
(catches bump-the-engine-forget-the-literal drift), and a docs/ADDING_MODELS.md
"Coherence rules" section codifying the compose/engine/patch separation.

42/42 gate green; resolver emits v0.22.0 for all migrated Qwen + Gemma slugs.
The remaining experimental/preview slugs still on vllm-nightly-clean migrate
in follow-up as each is validated, then that engine is deprecated.

Refs #254.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 11:27:17 +00:00

398 lines
19 KiB
Markdown

# Compose Generator — `scripts/generate-compose.sh` (v0.8.0, #141)
Operator/user guide for the v0.8.0 compose generator. It emits a
**minimal-reproduction** `docker-compose` for one in-scope profile by
replaying the *whole shipped service definition* that profile was captured
from, wiring (or deliberately omitting) the patches that profile depends on,
and refusing — loudly and cleanly — anything outside its scope.
This is the `[D]` substrate of the v0.8.x design. Its job is the narrow one
described by the v0.8.x headline scope:
> evaluate any safetensors HF repo; pull only vLLM-loadable supported ones,
> and only when the gates pass (or an explicit override is accepted)
The generator itself implements the *generation* slice of that: it does not
download, derive, or VRAM-gate (those are other v0.8.x phases). It takes an
already-resolved in-scope profile and produces the compose that reproduces it.
For the **contributor contract** governing the patch metadata this generator
consumes, see [`docs/PATCH_POLICY.md`](PATCH_POLICY.md). For the underlying
patch/arch/profile data model see
[`docs/PATCH_ATTRIBUTION.md`](PATCH_ATTRIBUTION.md).
---
## 1. Mission — reproduce + flag, NEVER repair
The single locked rule that explains every other behaviour:
> **The generator reproduces a known config and flags what is missing. It
> never repairs anything.**
Concretely, the shipped code guarantees:
- It **never rewrites the engine image** — the captured
`image: ${VLLM_IMAGE:-vllm/vllm-openai:nightly-${VLLM_NIGHTLY_SHA}}`
expression is passed through verbatim (see §5).
- It **never wires a patch whose drift-guard failed** — a failed
capability-scoped guard *omits* the patch (DEGRADED), a failed foundational
guard *refuses* the whole compose. It never edits the patch to make it
apply.
- It **never blind-passes `--trust-remote-code`** — a governed security slot
is stripped from the captured body and only ever emitted if the trc gate
explicitly permits, which it never does in-scope (see §6).
- It **synthesizes nothing** — every non-insertion-point line, including all
constants and param-slot `${VAR:-default}` expressions, reproduces
byte-for-byte from the captured shipped compose (see §4).
A generated compose that won't boot is a *true signal* that something in the
captured config drifted, not a bug to be silently patched around. Repair is a
separate, out-of-scope loop.
---
## 2. Scope and the two clean-refuse classes
The generator handles **non-Genesis, vLLM-only** profiles. Anything else is a
clean refusal (stderr message + non-zero exit), never a partial or
best-effort emit.
Scope is decided by two gates that run **first**, before any patch or
template work:
| # | Gate | Discriminator | Refuse message (verbatim shape) | Exit |
|---|---|---|---|---|
| 1 | Engine type | `engine.type != "vllm"` | `engine <id> type='<t>' != vllm; the #141 generator is non-Genesis vLLM only -> refuse (out of scope)` | `2` |
| 2 | Genesis-equipped | profile `genesis_equipped: true` | `profile <p> is genesis_equipped:true (<evidence>); Genesis-flag generation is permanently out of scope -> refuse` | `2` |
### `genesis_equipped` discriminator
`genesis_equipped` is captured per profile in
`scripts/lib/profiles/profile_runtime.yml` using the locked v0.8.x §6
discriminator: a profile is `genesis_equipped: true` iff its shipped compose
contains a `_genesis` / `GENESIS_PIN` / `GENESIS_ENABLE` token **OR** its
`kv_format` starts with `turboquant`. (It is *not* keyed off an engine's
`required_genesis` flag.) Observed examples:
```text
$ scripts/generate-compose.sh --profile vllm/tools-text
[generate-compose] REFUSE: profile vllm/tools-text is genesis_equipped:true
(compose contains GENESIS_* / _genesis token); Genesis-flag generation is
permanently out of scope -> refuse
$ scripts/generate-compose.sh --profile vllm/dual-tq3-mtp
[generate-compose] REFUSE: profile vllm/dual-tq3-mtp is genesis_equipped:true
(kv_format 'turboquant_3bit_nc' starts with 'turboquant'); Genesis-flag
generation is permanently out of scope -> refuse
$ scripts/generate-compose.sh --profile llamacpp/default
[generate-compose] REFUSE: engine llama-cpp-local type='llama.cpp' != vllm;
the #141 generator is non-Genesis vLLM only -> refuse (out of scope)
```
Genesis-flag compose generation is **permanently** out of scope (a locked
v0.8.x decision), not a deferred feature.
---
## 3. Input — `--profile` is authoritative
```
scripts/generate-compose.sh --profile <COMPOSE_REGISTRY key> [--out FILE] [--accept-degraded]
```
`--profile` is the **only** authoritative input. It must be a key in
`scripts/lib/profiles/compose_registry.py` (`COMPOSE_REGISTRY`). An unknown
profile is a usage error:
```text
$ scripts/generate-compose.sh --profile vllm/does-not-exist
[generate-compose] REFUSE: unknown profile 'vllm/does-not-exist' (not in COMPOSE_REGISTRY) # exit 64
```
### Convenience tuple (discovery aid only — NOT authoritative)
`--model` / `--engine` / `--kv` / `--tp` form a convenience tuple that
**lists candidate `--profile` values and exits non-zero (4)**. It never
generates — it is a discovery aid so you can find the authoritative profile
name:
```text
$ scripts/generate-compose.sh --model gemma-4-31b --engine vllm-stable
convenience tuple matched these profiles (re-run with an authoritative --profile):
--profile vllm/gemma-int8-mtp
--profile vllm/gemma-bf16-mtp
--profile vllm/gemma-mtp-tp1
# exit 4
```
No args at all → usage error (exit `64`).
### Output
`--out FILE` writes the compose there (parent dirs created) and prints a
one-line summary to stderr; without `--out` the compose goes to stdout. The
3-category provenance header (§7) is always part of the emitted file.
### Capacity values are the reference profile's — NOT fit-adapted to your hardware
The generated compose's capacity knobs — `--max-model-len`, `--gpu-memory-utilization`, `--max-num-seqs`, and the KV cache dtype — are copied **verbatim from the captured reference profile** (the shipped compose it reproduces). The generator does **not** run a fit solve, does **not** size context to *your* GPU's VRAM, and does **not** down-cast the KV dtype for capacity. So:
- On a card **smaller** than the reference target, the emitted `--max-model-len` may not boot — it is not shrunk for you.
- On a card **larger** than the reference target, you are leaving context/throughput on the table — it is not grown for you.
- For a **derived** (non-curated) model the KV dtype is whatever the model ships (often `bf16`); it is not optimised toward a denser hardware-legal format.
This is deliberate (Mission §1 — reproduce + flag, NEVER repair): the generated file is a **known-safe starting point**, not a hardware-tuned config. For the actual fit on *your* hardware, run `scripts/pull.sh <slug> --profile-like <key> --recommend` (or `tools/kv-calc.py --solve-max-ctx ...`) and tune the emitted `${MAX_MODEL_LEN}` (it is intentionally an env-overridable default) accordingly. An opt-in capacity optimiser is planned for a later release; until then, right-sizing is a deliberate user step.
### Running a generated compose — it is NOT relocatable
Per the whole-service-template model (§4), the generator copies the shipped
service definition **verbatim** — including its bind-mount *sources*, which are
**repo-relative** (e.g. `../../patches/<patch>/...`, `../../cache/...`, and the
`${MODEL_DIR:-../../../../../models-cache}` default). The generator deliberately
does **not** rewrite these (it synthesizes nothing). They resolve correctly only
when Docker Compose's project directory is the shipped compose's own directory.
Consequence: a generated file run from an arbitrary location (e.g.
`docker compose -f /somewhere/else/out.yml up`) has **dangling bind-mount
sources**. Docker silently creates empty directories at the missing source
paths, so e.g. a mounted chat-template file becomes a directory and vLLM aborts
at boot with `... looks like a file path, but it failed to be opened ...
Is a directory`.
Run a generated compose with `--project-directory` anchored to the shipped
compose's directory (the value of `compose_service_template.source` in
`profile_runtime.yml`), and set the same `MODEL_DIR` / `VLLM_IMAGE` the repo
launcher sets:
```
docker compose \
--project-directory models/<model>/vllm/compose/<topology> \
-f <generated-file> up -d
```
Equivalently, write `--out` into that shipped compose directory and run it
there. This is consistent with §10 (coexistence with the pre-baked tree): the
generated artifact is a drop-in *for that location*, not a portable standalone.
---
## 4. The whole-service-template model
The capture unit is the **entire shipped service definition**, recorded per
profile in `profile_runtime.yml` under `compose_service_template`. The
generator loads the file named at `compose_service_template.source` and
classifies every token into exactly one of three classes:
| Class | What it is | What the generator does |
|---|---|---|
| **param-slot** | `${TP}`, `${MAX_MODEL_LEN}`, `${GPU_MEMORY_UTILIZATION}`, `${KV_CACHE_DTYPE}`, `--max-num-seqs`, `--speculative-config`, ports, NVLink env | Reproduced as the shipped `${VAR:-default}` expression (the registry carries the resolved value; the env-default mechanism does the substitution at runtime). `--speculative-config` is **omitted entirely** when the profile has no drafter. |
| **governed-slot** | `--trust-remote-code` (and any future security/policy flag) | Captured but flagged *governed*. Emitted **only** when the gate state explicitly permits — which never happens in-scope (§6). |
| **constant** | The `image:` expression, `shm_size`, `ipc`, `deploy.resources`, model-cache volume, base `environment`/`entrypoint`, `--model`, `--served-model-name`, `--quantization`, `--dtype`, `--tool-call-parser`, `--chat-template`, `--reasoning-parser`, `--default-chat-template-kwargs`, `--enable-prefix-caching`, `--enable-chunked-prefill`, `--enforce-eager`, … | Reproduced **verbatim, byte-for-byte**. |
The **only** transformation the generator applies to the body is at **two
named insertion points**:
- `volumes:` — overlay / sidecar bind-mount lines.
- `entrypoint:` — sidecar / install-script invoke lines.
A selected+wired patch keeps its mount/invoke lines; a selected-but-omitted
patch (delivery-gap or failed-guard) has *its* lines stripped. **Everything
else is reproduced exactly** — the generator synthesizes nothing. For a
golden profile the compose-keyed selection reproduces precisely the patch set
the maintainer shipped, so the generated service body is byte-identical to
the shipped file outside the insertion points (the golden-parity invariant,
§9).
---
## 5. The image expression is preserved, never rewritten
The shipped composes use
`image: ${VLLM_IMAGE:-vllm/vllm-openai:nightly-${VLLM_NIGHTLY_SHA}}`. This is
a **captured constant** and is passed through **verbatim**. The engine pin
flows through the existing `VLLM_NIGHTLY_SHA` / `VLLM_IMAGE` env mechanism —
the generator does **not** rewrite the image line.
The engine pin is **validated** (§8), not substituted. Validation confirms
the captured config is internally consistent; it does not edit the artifact.
---
## 6. `--trust-remote-code` is a governed slot, never emitted in-scope
`--trust-remote-code` is governed under the locked v0.8.x §88 security model.
It is treated as a governed-slot, not a constant:
1. **Gate (step 5):** the arch's `requires_trust_remote_code` is read from
`arch_patches.yml`. If it is `true` or `unverified`, the generator
**refuses** (security refusal, exit `2`) — trc acknowledgement is the
pull-gate's job, not the generator's.
2. **Suppression:** when generation proceeds (arch trc = evidence-cited
`false`), `trc_emit` is provably `False`, and the generator **strips** any
`--trust-remote-code` token from the captured body before emitting.
The combination guarantees an in-scope generated compose **never
blind-passes `--trust-remote-code`**. Every golden triple is asserted to emit
no `--trust-remote-code` and report `meta.trc_emitted == False`. The header
states this explicitly:
```text
# NOTE: --trust-remote-code is a GOVERNED slot (locked §88); it is
# NOT emitted for any in-scope profile (arch trc gate = false).
```
Any future security/policy flag joins the governed-slot set and gets the same
treatment.
---
## 7. Patch selection, delivery gaps, drift guards (§4.1 graded drift)
Patch selection is **compose-keyed only**: patch `P` is selected for profile
`X` iff `X ∈ P.load_bearing_when[].composes`. Every other patch is
**EXCLUDED** (not load-bearing for this compose) — listed in header category
[3], emitted nowhere.
For each selected patch the generator classifies it, in this order:
1. **Delivery-gap first.** If the patch declares a `delivery_gaps[]` entry
covering this profile, it is **selected-but-undelivered**: its wiring is
omitted, a header WARNING records the gap's `issue` string, and the
drift-guard is **skipped entirely**. This is the structural acknowledgement
of a known coverage boundary (the #145 class — see
[`docs/PATCH_POLICY.md`](PATCH_POLICY.md)).
2. **Drift-guard (graded, §4.1).** For a will-be-wired patch the generator
records its `drift_guard`. The guard is a runtime import/boot/behavioral
probe that **cannot run at generation time** (no engine container here);
the contract is that the shipped, maintainer-tested state is "applies
cleanly" (= drift-guard-tested, a locked decision), so the patch is wired
and the guard is surfaced for the boot leg / operator to re-run. When a
guard *does* fail (the test harness drives this deterministically via
`CLUB3090_FORCE_GUARD_FAIL`; there is no other way to force a runtime
probe failure from a unit test), the **gap-before-guard** ordering and the
§4.1 grade decide the outcome:
- **capability-scoped** fail → patch **OMITTED**, compose flagged
**DEGRADED**. The run requires `--accept-degraded` to proceed; without
it the generator refuses with exit `3`. A failed patch is **never
wired**.
- **foundational** fail (or `on_fail: hard-refuse`) → **hard-refuse**
(exit `2`) — even *with* `--accept-degraded`. Weights/boot depend on it;
there is nothing safe to emit.
Observed degraded path (forced fail, no ack):
```text
$ scripts/generate-compose.sh --profile vllm/gemma-int8-mtp # CLUB3090_FORCE_GUARD_FAIL=gemma-vllm-gemma4-tool-parser-fixes
[generate-compose] REFUSE: DEGRADED: capability-scoped patch(es)
[gemma-vllm-gemma4-tool-parser-fixes] omitted after a failed drift-guard; re-run with
--accept-degraded to proceed # exit 3
```
With `--accept-degraded` the same case proceeds: the patch is in
`degraded_omitted` (never `wired`), its mount lines are physically absent
from the body, and the header carries a `WARNING: DEGRADED` block.
The generator **never repairs** — capability-scoped failure degrades,
foundational failure refuses; neither edits the patch.
---
## 8. Engine-pin `<id>@<sha>` validation
Step 3 validates the engine pin (it does **not** rewrite the image, §5). The
pin is derived as `<engine_id>@<sha>` from the engine's `install.spec` (the
concrete image tag), and must match an entry in the resolved arch row's
`engine_pin[]` with `loads: true`:
- `<id>` (no sha) or `<id>@<sha>` forms are both accepted.
- A pin that matches the engine but is `loads: false` → refuse, surfacing the
pin's `reason`.
- No `loads: true` match for the engine → refuse.
The arch row is resolved via `arch_model_xref` (`E.model ∈ model_slugs`), and
two further validations gate generation: `E.tp` must be in the arch's
`valid_tp.tp_divisors`, and `E.kv_format` must be in the engine's
`supported_kv_formats`. All are clean refusals (exit `2`) on failure. The
validated pin is recorded in the header:
```text
# engine-pin (validated, image NOT rewritten): vllm-nightly-clean@bf610c2f...
```
The `kv_format → --kv-cache-dtype` map (non-Genesis only) is exercised on
every run and unit-tested:
| `kv_format` | emitted `--kv-cache-dtype` |
|---|---|
| `bf16`, `fp16` | *(no arg)* |
| `fp8_e5m2` | `fp8_e5m2` |
| `fp8_e4m3` | `fp8` |
| `int8_per_token_head` | `auto+PTH` |
| `q4_0` | `q4_0` |
| `k8v4` | `k8v4` |
---
## 9. The 3-category header and the 5-triple golden-parity guarantee
Every generated compose carries a provenance header **above** the top-level
`services:` key (so the patch-attribution `service_body()` parser discards
it — header text can never create a false-positive reachability hit). The
header has three categories:
1. **selected + WIRED** — drift-guard-tested, wired at `volumes:`/`entrypoint:`.
2. **selected + UNDELIVERED** — delivery-gap or failed-guard; wiring omitted,
with the reason.
3. **EXCLUDED** — not load-bearing for this compose.
Plus the validated engine pin, the mission line, an optional `WARNING:
DEGRADED` block, and the governed-trc note.
**Golden-parity guarantee.** `scripts/tests/test-generate-compose.sh` asserts
five golden triples, all verified `genesis_equipped: false`, spanning every
in-scope engine class:
| Profile | Engine | Notes |
|---|---|---|
| `vllm/minimal` | `vllm-nightly-clean` | tp1, fp8, drafter=None; surfaces the qwen3coder delivery-gap as undelivered |
| `vllm/dual` | `vllm-nightly-clean` | tp2, fp8, MTP drafter |
| `vllm/gemma-bf16-mtp` | `vllm-stable` | gemma, bf16 |
| `vllm/gemma-int8-mtp` | `vllm-stable` | int8-PTH, multi-file overlay |
| `vllm/gemma-mtp-tp1` | `vllm-stable` | single-card fp8 risk path |
Per triple the test asserts: the semantic diff vs the shipped compose is
confined to the two patch insertion points (image expression + every constant
reproduce verbatim); selected+wired ⊆ shipped; wired patches pass `reaches()`
on the *generated* compose (actual wiring, not header text);
selected-but-undelivered patches are **not** reachable; the 3-category header
is present; and no `--trust-remote-code` is emitted. The refusal/degraded
matrix (genesis, llama.cpp, foundational hard-refuse, capability-scoped
degrade ± ack, convenience tuple) is asserted alongside. At least one triple
is booted on-rig.
---
## 10. Coexistence with the pre-baked compose tree
The generator does **not** replace or delete the existing hand-maintained
`models/<model>/<engine>/compose/...` tree. The shipped composes remain the
ground truth — they are exactly what the generator captures from and
reproduces. Operators who want the curated, maintainer-tested file keep using
it directly; the generator is for *deriving a minimal reproduction* of an
in-scope profile (e.g. to bisect drift, or as the `[D]` substrate of the
v0.8.x pull flow). There is no big-bang tree deletion in v0.8.0.
---
## 11. Exit codes
| Code | Meaning |
|---|---|
| `0` | compose emitted (possibly DEGRADED with `--accept-degraded`) |
| `2` | clean scope / validation / foundational refusal |
| `3` | capability-scoped DEGRADED, `--accept-degraded` absent |
| `4` | convenience tuple matched profiles (not authoritative — re-run with `--profile`) |
| `64` | argv / lookup misuse (no `--profile`, unknown profile) |