inference-spec-decode-train
Purpose
Give an arbitrary target LLM a speculative-decoding draft head. SpecForge's
worked EAGLE3 + DFlash recipes are hard-wired to one model
(the run_glm5p1_eagle3_offline.sh recipe bakes in the target path, the aux
layers [1,38,74], the glm5p1 chat template, and the GLM draft config). This
skill generalizes that stack so a new model gets the same train ->
validate -> promote loop without copy-pasting the GLM script.
It reuses SpecForge verbatim
(prepare_hidden_states.py, train_eagle3.py, train_dflash.py,
dflash_vllm_eval.py, convert_dflash_to_vllm.py, the slurm/*.sbatch
launchers) and only parameterizes the per-target inputs.
When to use
- A new model has a latency ceiling you want to beat with a trained draft head
and no public draft exists.
- You want to train a DFlash head as an alternative to a model's built-in MTP.
- The
inference-model-optimize orchestrator reached Phase 6.
Do not use this skill for:
deepseek_mtp / ngram -- there is nothing to train (MTP is a built-in head,
ngram is prompt-driven). Just set --speculative-config and validate
acceptance (Phase 7 of the orchestrator / the validation half here).
- Consuming a pre-trained public draft (e.g.
lightseekorg/kimi-k2.6-eagle3) --
pull it and serve it. No training needed.
- The acceptance A/B alone on an already-trained head -- you can run just the
validation phase below.
Example prompts
- "Train an EAGLE3 draft head for MiniMax-M2.7 and A/B its acceptance."
- "Build a DFlash speculator for this 70-layer model."
- "Generalize the GLM eagle3-train recipe to
zai-org/GLM-5.2."
/inference-spec-decode-train --target /models/base --method eagle3 --chat-template <name>
Prerequisites
The skill fails closed if any of these are not satisfied.
- The target base checkpoint (BF16 or FP8 -- NOT the NVFP4 serving copy)
staged on a shared FS / PVC the Slurm trainer can read.
- The SpecForge trainer container built for the target arch -- a new arch may
need a newer SGLang/transformers (a target arch class missing from the
trainer's SGLang is a kill-gate. Reconcile versions before any GPU spend).
- The target
config.json (for num_hidden_layers -> aux layers, and to
confirm the arch is supported by the trainer's SGLang).
- Free GPU nodes: plain K8s (e.g. GB300) via
kubectl GPU requests, or
Slurm-on-K8s (e.g. B200) via sinfo/squeue. Training is a
multi-hour job -- submit-and-queue, never preempt serving.
- A chat-template name registered in SpecForge's
specforge/data/template.py
for the target family (add one if absent, as was done for glm5p1).
Workflow
Phase 0: derive the per-target inputs + scaffold
State back the parameters and derive the EAGLE3 aux layers from
config.json: for an L-layer target the SpecForge convention is
[1, L//2 - 1, L - 4] (the GLM 78-layer target -> [1, 38, 74]). Resolve:
target-model-path, method (eagle3 | dflash), chat-template,
draft-config (a per-target draft-head JSON, seeded from the GLM
glm5p1-eagle3.json with num_layers/hidden_size/vocab taken from the
target config.json), num-gpus/tp-size, dataset.
The scaffolder lays down spec-decode/ in the bundle:
spec-decode/configs/<slug>-eagle3.json (+ <slug>-dflash.json) -- draft head
config rendered from the target config.json.
spec-decode/run-<slug>-offline.sh -- the generalized 2-step recipe
(parameterized clone of run_glm5p1_eagle3_offline.sh: env-driven
TARGET_MODEL_PATH, AUX_LAYERS, CHAT_TEMPLATE, DRAFT_CONFIG, METHOD).
spec-decode/slurm/ -- copies of the SpecForge *.sbatch launchers
(prepare-data, capture, train, eval) with the target paths parameterized.
spec-decode/dflash_vllm_eval.py + convert_dflash_to_vllm.py -- the
in-engine acceptance eval + checkpoint converter (verbatim from SpecForge).
spec-decode/deploy/<slug>-spec-canary.yaml -- the --speculative-config
canary deploy for the acceptance A/B.
Phase 1: prepare the offline dataset
Submit the data-prep sbatch (CPU): build the mixed UltraChat + ShareGPT JSONL
through the target's chat template. Gate: the JSONL exists with the expected
conversation count and renders cleanly through the chat template (a bad
loss-mask / template is the GLM "training loss stays zero" failure mode).
Phase 2: capture target hidden states
Submit the capture sbatch: prepare_hidden_states.py holds the target resident
at --tp-size and dumps aux hidden states at the derived AUX_LAYERS. Offline
(capture-then-train) is mandatory for huge MoE targets -- hold the target only
during capture, then iterate draft training cheaply. Gate: hidden-state shards
written for the full dataset.
Phase 3: train the draft head (submit-and-queue)
Submit the train sbatch (train_eagle3.py or train_dflash.py). This is the
multi-hour job. Submit it as a self-driving Slurm job and do NOT babysit (it
re-queues through preemption). Gate: training acc_0 / loss is non-degenerate
(a healthy run lands acc_0 around ~0.6-0.7. A head stuck near zero acceptance
is a train/serve mismatch to debug, not a result to ship).
Phase 4: convert to a vLLM-loadable draft
Run convert_dflash_to_vllm.py (DFlash) / the EAGLE3 export to produce a
draft the serving vLLM can load via --speculative-config.model=<path>. Stage
it on the serving PVC (e.g. /models/target/speculator/<slug>/<sha>/).
Phase 5: validate acceptance (the gate that matters)
Deploy the --speculative-config canary and measure in-engine acceptance:
spec-decode/dflash_vllm_eval.py -- the authoritative in-engine acceptance
(acceptance length per position) via vLLM metrics.
- vLLM Prometheus
spec_decode_num_accepted_tokens /
spec_decode_num_draft_tokens / spec_decode_num_accepted_tokens_per_pos
deltas over a driven bench window.
Run it as a same-node A/B: trained-draft arm vs the standing config (e.g. MTP
K=3, or no-spec). Gate: a measured acceptance-length win AND an end-to-end
TPOT win under cudagraph (NOT eager -- an eager spec-decode "win" is host
overhead, not GPU work). A draft head that loses -- even one whose training
acceptance looked healthy -- is do-not-ship: keep the standing config and
report why.
Teardown
Cancel any running Slurm jobs you own. Tear down the canary deploy by label
(kubectl delete deploy,pod -l experiment=<id-slug>). Keep the trained draft
artifact on the PVC until the parent run promotes or discards it.
Generalization map (GLM-hardcoded -> parameter)
| GLM-5.1 hardcoded value |
Generalized parameter |
Derivation |
TARGET_MODEL_PATH=/mnt/data/models/GLM-5.1-FP8 |
--target-model-path |
operator input (base, not NVFP4) |
--aux-hidden-states-layers 1,38,74 |
AUX_LAYERS |
[1, L//2-1, L-4] from config.json num_hidden_layers |
--chat-template glm5p1 |
--chat-template |
target family. Add to SpecForge template.py if absent |
configs/glm5p1-eagle3.json |
spec-decode/configs/<slug>-eagle3.json |
rendered from target config.json (hidden_size/vocab/layers) |
| EAGLE3 only |
`--method eagle3 |
dflash` |
TP_SIZE=8 (single B200 node) |
--tp-size / --num-gpus |
from the target's fit math |
Verdict rigor (DRAFT vs VERDICT)
Training acc_0 is a training-time signal, not a serve verdict. The
ship/no-ship claim is the serve acceptance + TPOT A/B in Phase 5, which
follows the standard rule: DRAFT unless same-node + >=3 trials + metric-isolated
(median TPOT/ITL) + both arms under matching cudagraph_mode. Acceptance length
must be measured in-engine (the vLLM counters), never inferred from end-to-end
latency alone.
Safety
- Never preempt serving -- training uses idle Slurm nodes only. Submit-and-
queue with a self-driving Job, do not babysit or drain a serving node. A
drained quiet window is for bench windows only, never for training.
- Standing config stays until a measured win -- the standing
--speculative-config (e.g. MTP K=3) is not changed until the Phase-5 A/B
shows a real acceptance + TPOT win under cudagraph.
- Experiment isolation -- the canary deploy + any draft-staging objects are
experiment-prefixed +
experiment=<id-slug> labeled. Teardown by label.
- Local fork only -- keep any SpecForge changes on a local fork. No upstream
PR / external outreach without explicit per-turn operator approval.
Source-of-truth references
- SpecForge
examples/run_glm5p1_eagle3_offline.sh
-- the 2-step offline recipe the generalized run-<slug>-offline.sh clones.
- vLLM
vllm/config/speculative.py -- the --speculative-config schema
(method, model, num_speculative_tokens, draft_tensor_parallel_size).
docs/METHODOLOGY.md -- benchmark hygiene (eager vs cudagraph for
spec-decode), kernel-work classification, and verdict rigor.
Contact
Open an issue in this repository.
Full-context reporting (no bare numbers)
Per the methodology canon "Every performance number carries its full context (no bare
numbers)" (docs/METHODOLOGY.md, "Full-context reporting"): every number this
skill emits MUST carry its full measurement-context descriptor, and every comparison MUST be
matched on it. A bare tok/s / TPOT / BW / %SoL / speedup is a defect - it cannot set a
default, ship a config, or appear in a report.
- Identity: model (+HF path), hardware (exact ceiling token
GB300/B200), quant, kv-cache dtype.
- Parallelism: TP, DP (replicas), PP, EP, parallel_strategy.
- Serving cfg: max-num-seqs, max-num-batched-tokens, gpu-memory-utilization, max-model-len, cudagraph_mode/enforce_eager, async_scheduling, prefix-caching.
- Workload: dataset, ISL/OSL (or mean in/out tokens), concurrency, num-prompts.
- Regime: warm vs cold. Latency vs throughput tier.
- Stack: image/vllm commit, bench backend, serving engine.
- Grounding:
%SoL (+ ceiling key from configs/sol-ceilings.yaml - never inline a peak), sol_rigor (L1-L4), trials n (mean±std), same-node, baseline named.
- Per-number exact shape (no smoothing): when reporting more than one number, keep EACH with its own exact shape (ISL/OSL, concurrency, dataset, regime) - never normalize a set to one uniform descriptor that hides per-point variation (e.g.
c=1 @ ISL1024/OSL256 + c=64 @ ISL4096/OSL512, NOT one shared "random").
If this skill produces a measurement (tok/s, latency, %SoL, speedup), follow the
rigor discipline: capture L1 zymtrace + L3 DCGM (L4 ncu where feasible)
Speed-of-Light and publish --strict. Canonical map: docs/METHODOLOGY.md. Skills that
do not produce measurements are exempt (docs/METHODOLOGY.md "Speed-of-light framing").
Next lever / BREAKTHROUGH (Grind Mandate)
If this skill emits a measured result, its output MUST end by naming the next perf lever,
its expected unlock (direction + rough magnitude), and the gate that proves/refutes it,
per the Grind Mandate (docs/METHODOLOGY.md, "Always be grinding"). A
measured win is the new floor, not the finish -- so do everything we can to find the next
BREAKTHROUGH: the highest-EV unlock toward Speed-of-Light (a new champion / kernel / router /
quant / parallelism / spec-decode win, or an unblocked stack), not just the next micro-lever.
Rank the candidate breakthrough levers by value x cost (the GRIND FRONTIER, perftunereport value_view), pursue the top, bank the rest with evidence. Record WHY a refuted lever loses,
update the standing frontier in the active bundle's HANDOFF.md. Never conclude
"exhausted/optimal/done" without an explicit next-lever frontier (an empty frontier AND a
documented SoL wall only). Delete this section ONLY if the skill produces no measurements.
1---2name: inference-spec-decode-train3description: Train + validate a speculative-decoding draft head (EAGLE3 or DFlash) for an ARBITRARY target LLM, generalizing the GLM-5.1-only SpecForge recipe so any new model gets one. Parameterizes what that offline recipe hard-codes: target path, aux-hidden-states layers (derived [1, L/2-1, L-4] from config.json num_layers), chat template, draft-head config, and method. Wires the SpecForge prepare_hidden_states -> train_eagle3 / train_dflash -> convert-to-vLLM flow on Slurm, then gates on a measured acceptance-length A/B (dflash_vllm_eval.py + vLLM spec_decode_* counters) vs the standing config. This is the train-spec-decode phase of inference-model-optimize, usable standalone. Triggers on "train an EAGLE3 draft", "train a DFlash head", "build a speculative decoder for <model>", "spec-decode draft training", "draft model acceptance", "SpecForge for <model>", "generalize eagle3-train", or any combination of "train / build / validate" with "eagle3 / dflash / draft / speculative / spec-decode".4license: MIT5---67# inference-spec-decode-train89## Purpose1011Give an arbitrary target LLM a speculative-decoding draft head. SpecForge's12worked EAGLE3 + DFlash recipes are hard-wired to one model13(the `run_glm5p1_eagle3_offline.sh` recipe bakes in the target path, the aux14layers `[1,38,74]`, the `glm5p1` chat template, and the GLM draft config). This15skill **generalizes** that stack so a new model gets the same train ->16validate -> promote loop without copy-pasting the GLM script.1718It reuses SpecForge verbatim19(`prepare_hidden_states.py`, `train_eagle3.py`, `train_dflash.py`,20`dflash_vllm_eval.py`, `convert_dflash_to_vllm.py`, the `slurm/*.sbatch`21launchers) and only parameterizes the per-target inputs.2223## When to use2425- A new model has a latency ceiling you want to beat with a trained draft head26 and no public draft exists.27- You want to train a DFlash head as an alternative to a model's built-in MTP.28- The `inference-model-optimize` orchestrator reached Phase 6.2930Do **not** use this skill for:3132- `deepseek_mtp` / `ngram` -- there is nothing to train (MTP is a built-in head,33 ngram is prompt-driven). Just set `--speculative-config` and validate34 acceptance (Phase 7 of the orchestrator / the validation half here).35- Consuming a pre-trained public draft (e.g. `lightseekorg/kimi-k2.6-eagle3`) --36 pull it and serve it. No training needed.37- The acceptance A/B alone on an already-trained head -- you can run just the38 validation phase below.3940## Example prompts4142- "Train an EAGLE3 draft head for MiniMax-M2.7 and A/B its acceptance."43- "Build a DFlash speculator for this 70-layer model."44- "Generalize the GLM eagle3-train recipe to `zai-org/GLM-5.2`."45- `/inference-spec-decode-train --target /models/base --method eagle3 --chat-template <name>`4647## Prerequisites4849The skill **fails closed** if any of these are not satisfied.50511. The **target** base checkpoint (BF16 or FP8 -- NOT the NVFP4 serving copy)52 staged on a shared FS / PVC the Slurm trainer can read.532. The SpecForge trainer container built for the target arch -- a new arch may54 need a newer SGLang/transformers (a target arch class missing from the55 trainer's SGLang is a kill-gate. Reconcile versions before any GPU spend).563. The target `config.json` (for `num_hidden_layers` -> aux layers, and to57 confirm the arch is supported by the trainer's SGLang).584. Free GPU nodes: plain K8s (e.g. GB300) via `kubectl` GPU requests, or59 Slurm-on-K8s (e.g. B200) via `sinfo`/`squeue`. Training is a60 multi-hour job -- submit-and-queue, never preempt serving.615. A chat-template name registered in SpecForge's `specforge/data/template.py`62 for the target family (add one if absent, as was done for `glm5p1`).6364## Workflow6566### Phase 0: derive the per-target inputs + scaffold6768State back the parameters and **derive the EAGLE3 aux layers from69`config.json`**: for an `L`-layer target the SpecForge convention is70`[1, L//2 - 1, L - 4]` (the GLM 78-layer target -> `[1, 38, 74]`). Resolve:7172- `target-model-path`, `method` (`eagle3` | `dflash`), `chat-template`,73 `draft-config` (a per-target draft-head JSON, seeded from the GLM74 `glm5p1-eagle3.json` with `num_layers`/`hidden_size`/vocab taken from the75 target `config.json`), `num-gpus`/`tp-size`, dataset.7677The scaffolder lays down `spec-decode/` in the bundle:7879- `spec-decode/configs/<slug>-eagle3.json` (+ `<slug>-dflash.json`) -- draft head80 config rendered from the target `config.json`.81- `spec-decode/run-<slug>-offline.sh` -- the generalized 2-step recipe82 (parameterized clone of `run_glm5p1_eagle3_offline.sh`: env-driven83 `TARGET_MODEL_PATH`, `AUX_LAYERS`, `CHAT_TEMPLATE`, `DRAFT_CONFIG`, `METHOD`).84- `spec-decode/slurm/` -- copies of the SpecForge `*.sbatch` launchers85 (prepare-data, capture, train, eval) with the target paths parameterized.86- `spec-decode/dflash_vllm_eval.py` + `convert_dflash_to_vllm.py` -- the87 in-engine acceptance eval + checkpoint converter (verbatim from SpecForge).88- `spec-decode/deploy/<slug>-spec-canary.yaml` -- the `--speculative-config`89 canary deploy for the acceptance A/B.9091### Phase 1: prepare the offline dataset9293Submit the data-prep sbatch (CPU): build the mixed UltraChat + ShareGPT JSONL94through the target's chat template. Gate: the JSONL exists with the expected95conversation count and renders cleanly through the chat template (a bad96loss-mask / template is the GLM "training loss stays zero" failure mode).9798### Phase 2: capture target hidden states99100Submit the capture sbatch: `prepare_hidden_states.py` holds the target resident101at `--tp-size` and dumps aux hidden states at the derived `AUX_LAYERS`. Offline102(capture-then-train) is mandatory for huge MoE targets -- hold the target only103during capture, then iterate draft training cheaply. Gate: hidden-state shards104written for the full dataset.105106### Phase 3: train the draft head (submit-and-queue)107108Submit the train sbatch (`train_eagle3.py` or `train_dflash.py`). This is the109multi-hour job. Submit it as a self-driving Slurm job and do NOT babysit (it110re-queues through preemption). Gate: training `acc_0` / loss is non-degenerate111(a healthy run lands `acc_0` around ~0.6-0.7. A head stuck near zero acceptance112is a train/serve mismatch to debug, not a result to ship).113114### Phase 4: convert to a vLLM-loadable draft115116Run `convert_dflash_to_vllm.py` (DFlash) / the EAGLE3 export to produce a117draft the serving vLLM can load via `--speculative-config.model=<path>`. Stage118it on the serving PVC (e.g. `/models/target/speculator/<slug>/<sha>/`).119120### Phase 5: validate acceptance (the gate that matters)121122Deploy the `--speculative-config` canary and measure **in-engine acceptance**:123124- `spec-decode/dflash_vllm_eval.py` -- the authoritative in-engine acceptance125 (acceptance length per position) via vLLM metrics.126- vLLM Prometheus `spec_decode_num_accepted_tokens` /127 `spec_decode_num_draft_tokens` / `spec_decode_num_accepted_tokens_per_pos`128 deltas over a driven bench window.129130Run it as a same-node A/B: trained-draft arm vs the standing config (e.g. MTP131K=3, or no-spec). Gate: a measured **acceptance-length win** AND an end-to-end132TPOT win under cudagraph (NOT eager -- an eager spec-decode "win" is host133overhead, not GPU work). A draft head that loses -- even one whose training134acceptance looked healthy -- is **do-not-ship**: keep the standing config and135report why.136137### Teardown138139Cancel any running Slurm jobs you own. Tear down the canary deploy by label140(`kubectl delete deploy,pod -l experiment=<id-slug>`). Keep the trained draft141artifact on the PVC until the parent run promotes or discards it.142143## Generalization map (GLM-hardcoded -> parameter)144145| GLM-5.1 hardcoded value | Generalized parameter | Derivation |146| --- | --- | --- |147| `TARGET_MODEL_PATH=/mnt/data/models/GLM-5.1-FP8` | `--target-model-path` | operator input (base, not NVFP4) |148| `--aux-hidden-states-layers 1,38,74` | `AUX_LAYERS` | `[1, L//2-1, L-4]` from `config.json` `num_hidden_layers` |149| `--chat-template glm5p1` | `--chat-template` | target family. Add to SpecForge `template.py` if absent |150| `configs/glm5p1-eagle3.json` | `spec-decode/configs/<slug>-eagle3.json` | rendered from target `config.json` (hidden_size/vocab/layers) |151| EAGLE3 only | `--method eagle3 | dflash` | operator input |152| `TP_SIZE=8` (single B200 node) | `--tp-size` / `--num-gpus` | from the target's fit math |153154## Verdict rigor (DRAFT vs VERDICT)155156Training `acc_0` is a training-time signal, not a serve verdict. The157ship/no-ship claim is the **serve acceptance + TPOT A/B** in Phase 5, which158follows the standard rule: DRAFT unless same-node + >=3 trials + metric-isolated159(median TPOT/ITL) + both arms under matching `cudagraph_mode`. Acceptance length160must be measured in-engine (the vLLM counters), never inferred from end-to-end161latency alone.162163## Safety164165- **Never preempt serving** -- training uses idle Slurm nodes only. Submit-and-166 queue with a self-driving Job, do not babysit or drain a serving node. A167 drained quiet window is for bench windows only, never for training.168- **Standing config stays until a measured win** -- the standing169 `--speculative-config` (e.g. MTP K=3) is not changed until the Phase-5 A/B170 shows a real acceptance + TPOT win under cudagraph.171- **Experiment isolation** -- the canary deploy + any draft-staging objects are172 experiment-prefixed + `experiment=<id-slug>` labeled. Teardown by label.173- **Local fork only** -- keep any SpecForge changes on a local fork. No upstream174 PR / external outreach without explicit per-turn operator approval.175176## Source-of-truth references177178- SpecForge `examples/run_glm5p1_eagle3_offline.sh`179 -- the 2-step offline recipe the generalized `run-<slug>-offline.sh` clones.180- vLLM `vllm/config/speculative.py` -- the `--speculative-config` schema181 (`method`, `model`, `num_speculative_tokens`, `draft_tensor_parallel_size`).182- `docs/METHODOLOGY.md` -- benchmark hygiene (eager vs cudagraph for183 spec-decode), kernel-work classification, and verdict rigor.184185## Contact186187Open an issue in this repository.188189## Full-context reporting (no bare numbers)190191Per the methodology canon "Every performance number carries its full context (no bare192numbers)" (`docs/METHODOLOGY.md`, "Full-context reporting"): every number this193skill emits MUST carry its full measurement-context descriptor, and every comparison MUST be194matched on it. A bare `tok/s` / TPOT / BW / %SoL / speedup is a defect - it cannot set a195default, ship a config, or appear in a report.196- **Identity:** model (+HF path), hardware (exact ceiling token `GB300`/`B200`), quant, kv-cache dtype.197- **Parallelism:** TP, DP (replicas), PP, EP, parallel_strategy.198- **Serving cfg:** max-num-seqs, max-num-batched-tokens, gpu-memory-utilization, max-model-len, cudagraph_mode/enforce_eager, async_scheduling, prefix-caching.199- **Workload:** dataset, ISL/OSL (or mean in/out tokens), concurrency, num-prompts.200- **Regime:** warm vs cold. Latency vs throughput tier.201- **Stack:** image/vllm commit, bench backend, serving engine.202- **Grounding:** `%SoL` (+ ceiling key from `configs/sol-ceilings.yaml` - never inline a peak), sol_rigor (L1-L4), trials n (mean±std), same-node, baseline named.203- **Per-number exact shape (no smoothing):** when reporting more than one number, keep EACH with its own exact shape (ISL/OSL, concurrency, dataset, regime) - never normalize a set to one uniform descriptor that hides per-point variation (e.g. `c=1 @ ISL1024/OSL256` + `c=64 @ ISL4096/OSL512`, NOT one shared "random").204205If this skill produces a measurement (tok/s, latency, %SoL, speedup), follow the206rigor discipline: capture L1 zymtrace + L3 DCGM (L4 ncu where feasible)207Speed-of-Light and publish `--strict`. Canonical map: `docs/METHODOLOGY.md`. Skills that208do not produce measurements are exempt (`docs/METHODOLOGY.md` "Speed-of-light framing").209210## Next lever / BREAKTHROUGH (Grind Mandate)211212If this skill emits a measured result, its output MUST end by naming the **next perf lever**,213its **expected unlock** (direction + rough magnitude), and the **gate** that proves/refutes it,214per the Grind Mandate (`docs/METHODOLOGY.md`, "Always be grinding"). A215measured win is the new floor, not the finish -- so **do everything we can to find the next216BREAKTHROUGH**: the highest-EV unlock toward Speed-of-Light (a new champion / kernel / router /217quant / parallelism / spec-decode win, or an unblocked stack), not just the next micro-lever.218Rank the candidate breakthrough levers by value x cost (the GRIND FRONTIER, `perftunereport219value_view`), pursue the top, bank the rest with evidence. Record WHY a refuted lever loses,220update the standing frontier in the active bundle's `HANDOFF.md`. Never conclude221"exhausted/optimal/done" without an explicit next-lever frontier (an empty frontier AND a222documented SoL wall only). Delete this section ONLY if the skill produces no measurements.