# Engine extension: add a new playground feature axis Loaded on demand by the `cookbook-add-model` skill. **Rare** — adding a model cookbook is data-only and never needs this. The current 7 built-in axes (`attention`, `moe`, `parsers`, `speculative`, `pdDisagg`, `hicache`, `hisparse`) already cover the SGLang feature surface most cookbooks need. **Model-specific features are config DATA, not engine code.** The axis handlers read options / flags / env / gating straight from `config.playgroundFeatures`, so a model-specific feature is added as data on an existing axis with NO engine edit — MegaMoE W4A4 is not its own axis, it's config data on `moe` (a `megamoe` backend option + a `megamoeQuant` sub-select). Reach for this file ONLY when a feature's *shape* — its title + the flag family it strips + its option/state model — is something no existing axis can express. **When you do extend, build a GENERIC primitive, never a model-named handler.** The right unit is a reusable shape (e.g. "a titled single-select that strips a configurable flag family and splices the picked option's flags" — exactly the `speculative` handler's shape, minus its hardcoded title + `--speculative-*` strip list). Parameterize title, strip-prefixes, and options from config so the next model of that shape is pure config. Do NOT add a `kvcache` / `` handler that hardcodes one model's flag — that's the model-specific-code-in-the-engine anti-pattern this architecture exists to avoid. (Precedent: Nemotron3's "KV Cache DType" and Qwen3's mamba-cache select are the SAME single-select shape → one generic primitive serves both, then both are config.) **A new axis is backward-compatible — zero churn on existing configs.** The runtime is opt-in per key: the apply/render loop does `const fc = pgFeatures[axisId]; if (!fc) continue;`, so any config that doesn't declare the key never sees the axis. And a model-specific axis does NOT join the opt-out "general axes ship on every cookbook" set (that set is an authoring convention for NEW configs, not a runtime default) — so review-pr won't flag existing pages for lacking it, and you never touch a merged config. Only the models that expose the control declare it. Touches `_playground.jsx` only. For the per-model config/cells/MDX reference see [authoring-reference.md](authoring-reference.md). --- ## 3.1 Decide Before touching the engine, confirm: - The feature cannot be expressed as data on an existing axis (a new MoE backend belongs in `moe.backend.options`; a new parser in `parsers.items`; a new spec preset in `speculative.options`). This is the common case — most "new features" are new options, not new shapes. - The *shape* is genuinely new (state model + strip pattern), AND you are adding it as a GENERIC config-parameterized axis (title / strip-prefixes / options all from config), not a one-model handler. If you'd hardcode a specific flag like `--kv-cache-dtype`, stop — generalize the shape instead. - The shape has a clean strip-prefix → emit-flag pattern. If unsure, add it as data first (in one cookbook's config under an existing axis) before promoting it to a built-in axis. ## 3.2 Pick the axis id and state shape The axis id is the key in both `config.playgroundFeatures` and the internal `deltas` object. Use camelCase, descriptive but short: `mambaCache`, `attentionBackend`, `kvCacheDtype`. The state shape is whatever `initState` returns. Common shapes: - Single-select: a string sentinel (e.g. `"disabled"` / `"current"` / an option id). - Multi-toggle: `{[itemId]: bool}`. - Sub-knobs: `{[knobId]: value | null}`. - Compound (axis with its own internal sub-state, like PD-Disagg's `{mode, ibDevice}`): a plain object. Pick ONE "inherit base" sentinel and document it in the handler comment. ## 3.3 Implement the handler Add one entry to `AXIS_HANDLERS` in `_playground.jsx`. The handler owns everything: state init, apply (strip+insert), hidden-revert, AND the JSX render. Engine main loop iterates `AXIS_HANDLERS` and calls each method by name — adding a new axis is genuinely a one-place change. Template: ```js // ---- Axis: ---------------------------------------------------- // <one-paragraph description of what this axis controls and why it // exists. Mention the SGLang feature it wraps and the strip/insert // policy.> <axisId>: { initState: (fc) => /* initial state value */, // Called when base cell changes. Return new value if the picked option // is now hidden by a constraint; otherwise return value unchanged. // Disabled picks are intentionally NOT auto-reverted (soft warning). revertHidden: (value, fc, base, h) => { // ... return value or a new value return value; }, // Pure function. Receives the current (flags, env) and returns the next // (flags, env). Do NOT mutate inputs. The `value` argument is whatever // initState returned. The `fc` argument is config.playgroundFeatures[axisId]. // The `sel` argument is the current base cell selection. The `h` // argument is the helpers bundle (strip/insert primitives + anchors). apply: ({ flags, env, value, fc, sel, h, derived }) => { if (/* value is the inherit-base sentinel */) return { flags, env }; flags = h.stripFlagsByFirstToken(flags, [/* prefixes this axis owns */]); if (/* an option is picked */) { flags = h.insertAfter(flags, h.ANCHOR_NEAR_<X>, [/* new flags */]); // or: flags = h.insertBeforeTail(flags, [/* new flags */]); // if the axis mutates env: // env = h.stripEnvByPrefix(env, fc.stripEnv || []); // env = [...env, /* additional env vars */]; } return { flags, env }; }, // Optional: read the base cell's flag array back into the same shape // initState/apply use. Render shows this as the default selection // (dropdown option or checked chip) when the state slot is the inherit // sentinel — so the user sees the cell's actual --tp / MoE backend / // spec preset instead of an opaque "Auto." When derive returns a real // value, the inherit-sentinel option is hidden from the control. Apply // also receives the derived // value (as `derived`) and may use it as a no-op shortcut when the // user's pick matches base. Skip when your axis owns flags that never // appear in base cells (PD-Disagg / HiCache). // deriveFromBase: (cell, fc, h) => ({ ... }) | null, // Optional: hints for the renderer. Currently only pdDisagg uses this // to report its role banner. Omit if not needed. // getRenderHints: (value, fc) => ({ pdMode: ... }) | null, // Returns the axis card JSX. The outer div MUST have key={axisId} so // React can track it in the engine's map loop. Return null for // axis-level gating (e.g. HiSparse when the live PD mode isn't `decode`). Lay out as a single // compact horizontal row: title on the left, fields after. render: ({ axisId, value, setValue, fc, base, s, h, renderChip, renderSelect, derived }) => { if (/* axis-level gating fails */) return null; return ( <div key={axisId} style={s.card}> <div style={s.compactRow}> <span style={s.axisTitle}>Axis Title</span> {/* For multi-option fields, use renderSelect(...) — the default. For on/off toggles or single-select chip groups, use renderChip instead (see "Control choice" in the conventions below). Read state from `value`; write via `setValue(next)` (replaces the whole axis slot). */} <span style={s.field}> <span style={s.fieldLabel}>Field</span> {renderSelect(value.slot, fc.entries, (v) => setValue({ ...value, slot: v }), base)} </span> </div> </div> ); }, }, ``` **Important conventions**: - Insert the entry in the position you want it rendered. `AXIS_HANDLERS` is iterated in insertion order for both render and apply. - Use `h.ANCHOR_NEAR_*` constants for insertion. Add a new anchor to the helpers bundle if your axis needs to land somewhere new in the flag block. - Use lowercase HTML JSX tags only. Capitalized tags get rebound by Mintlify. - Inside `render`, read state via `value` (the slice for this axis). Write state via `setValue(next)` (replaces the whole slice). For compound axes, do `setValue({ ...value, [k]: nextK })`. - Layout: one `s.compactRow` per axis card, `s.axisTitle` for the leading label, one `s.field` per (label + input) pair. - Control choice — `renderSelect` vs `renderChip`: - `renderSelect(current, entries, onPick, base, labelFor?, opts?)` is the **default** compact control (a `<select>` dropdown). It filters hidden chips and disables greyed-out ones internally — no per-chip `evaluateChip` loop needed in the render body. Most axes use it (attention, moe, pdDisagg, hisparse, hicache). Pass `{ hideValues: [<sentinel>] }` when your `deriveFromBase` resolved to a real value, so the inherit-sentinel ("Auto" / "Inherited" / "current") doesn't clutter the dropdown. - `renderChip(label, current, value, onPick, { disabled?, disabledReason? })` renders a **button** instead of a dropdown row. Use it for a chip group when you want the options laid out as buttons. It serves two shapes: - **Multi-toggle** (Parsers) — one independent on/off chip per item; `current` is that item's effective bool, `value` is `true`, so the chip is "checked" when the item is on. - **Single-select** (Speculative) — a radio-style group; pass the group's effective value as `current` and each option's id as `value`, so exactly one chip is checked (`current === value`). Chip groups own their visibility/disable filtering: loop `h.evaluateChip(opt, base)` in the render body, skip `c.hidden`, filter the inherit-sentinel yourself when `deriveFromBase` resolved to a real value, and forward `c.disabled` / `c.disableReason` into `renderChip`'s opts (this is what surfaces a disabled chip's tooltip, e.g. a "Coming soon" entry). - Selected chips use the same terracotta (`#D45D44`) as the Deploy panel's selected button, so both widgets read as one visual system. Don't introduce a per-axis accent color. - Default-from-base: if your axis can be read out of base cells' flags, implement `deriveFromBase` and have your render show the derived value when state is the sentinel (e.g. `const eff = value.tp !== null ? value.tp : (derived && derived.tp)`). This is what makes a fresh playground load show the user's actual recipe instead of "auto." Flag-parsing helpers on `h`: `parseIntFlag`, `hasFlag`, `findFlagArg`. - **Avoid the `in` operator wrapped in unary** (`!(x in y)`). Mintlify's AST walker crashes on it (`TypeError: this[e] is not a function`). Use `obj.key === undefined` or `obj.id !== undefined` instead. Bare `if (key in obj)` (no surrounding `!`) is fine. ## 3.4 Document the per-cookbook schema Edit the file header in `_playground.jsx` to add your new axis to the "Recognised keys" list, with a one-line description of its schema. Optionally add a paragraph below explaining its strip/insert policy. Update the §2.3 axis table in [authoring-reference.md](authoring-reference.md) to list the new axis. ## 3.5 Migrate cookbooks that need it For each cookbook that should expose this axis, add a `playgroundFeatures.<axisId>` entry to its config. Verify the chip group renders, options apply correctly, and the diff matches expectations. --- ## Pitfalls (engine work) **Insertion anchor misses** — `insertAfter` falls back to right-after `--model-path` if none of its anchor prefixes are present. If your axis emits flags that should land somewhere specific, include the most likely anchor prefixes in your call. Order doesn't matter (set semantics). **Conditional strips** — Some axes strip ONLY when overridden (`attention.tp`, `moe.backend` (incl. the MegaMoE quant env), `speculative`). Others strip UNCONDITIONALLY whenever declared (`parsers`, `pdDisagg`, `hicache`). The header comment in `AXIS_HANDLERS` documents which policy each axis uses; follow the same pattern when adding a new axis. If unsure, prefer conditional strip — it preserves base behavior when the user does not opt in. **Closure of `AXIS_HANDLERS`** — Inside a handler method, you can reference `AXIS_HANDLERS.<otherAxis>` for cross-handler calls (no built-in axis currently needs this, but it works because `AXIS_HANDLERS` is in lexical scope). Do NOT use this for general logic — it tightly couples handlers. Reserve it for one handler's helpers shared between its own `render` and `revertHidden`. --- ## Review checklist for a new-axis PR - [ ] `AXIS_HANDLERS` is the ONLY place that mentions the new axis id (apart from per-cookbook config). No `if (axisId === '<new>')` branches anywhere in the engine. - [ ] `initState` is deterministic and idempotent (does not depend on the base cell). - [ ] `apply` is pure — does not mutate inputs. - [ ] `revertHidden` returns the same reference when nothing changed (avoids unnecessary re-renders). - [ ] `render` returns `null` when axis-level gating fails (whole card hidden) — does not render an empty placeholder. - [ ] `render` sets `key={axisId}` on its outer element. - [ ] No `!(x in y)` patterns introduced (Mintlify AST walker crashes). - [ ] File header lists the new axis in "Recognised keys". - [ ] The §2.3 table in `authoring-reference.md` lists the new axis. - [ ] One existing cookbook config is updated to consume the new axis, and visual verification shows the diff is correct.