--- summary: 'How an omitted slide layout gets picked (the four-step weighted seed stack) and how seed revision-stability works' read_when: - debugging why a slide resolved to an unexpected layout - touching layout selection, seed derivation, or the validate/render parity path - adding a strategy's layoutTendencies/identityTendencies, a beat's BEAT_TENDENCIES, a theme's layoutTendencies, or a narrativesOnly-restricted layout --- # Selection and seed ## The single source of truth `resolveLayoutId` (`src/svg/layout-selection.ts:291`) is the *only* place the selection mechanics live. `full-slide-svg.tsx`'s render path and `ir-quality.ts`'s validate-time density gate both call it — never a second copy. The module's own header states the invariant this exists to protect: "what validate approved is what render draws." Do not duplicate this function's body — extend or call it. An explicit `slide.layout` short-circuits every step below unconditionally (bypasses curation, `narrativesOnly`, and weighting — it is a pin, not a preference). ## Four deterministic steps 1. **Full pool**: every registered layout for the slide's page type (`LAYOUT_REGISTRY`, filtered to `kind: "archetype"`), minus any `pinOnly` member (`excludePinOnly`, `LayoutDefinition.pinOnly` — quote-stage is the first) — a pin-only layout never enters this pool at all, it only reaches a slide through the explicit-pin short-circuit above. 2. **theme.layouts boundary**: narrowed to the theme's curated set for that page type — the caller's job before calling `resolveLayoutId` (full set for all 17 built-ins today, see `docs/concepts.md`). This is the one hard boundary in the whole chain: a theme's own `layoutTendencies` (step 4 below) can only reweight an id already inside this same page type's curated set — it is a soft weight *within* the boundary, never a second whitelist that could widen it. 3. **`narrativesOnly` hard filter** (`filterByNarrativesOnly`, `src/svg/layouts/registry.ts:210-215` — renamed from `scenariosOnly`/`filterByScenariosOnly` in the vocabulary-v4 rename's internal-name sweep, spec §16, an internal registry detail): drops any candidate whose allowlist excludes the resolved `strategy`. Rare — no built-in layout sets it today. 4. **Narrative soft weight combined with beat soft weight and theme soft weight + seeded pick** (one combined step): the strategy weight source is picked per slide type (`tendencyIdsFor`, `layout-selection.ts`) — content reads `STRATEGY_DEFINITIONS[strategy].layoutTendencies` (`src/narrative/index.ts`), cover/chapter/ending each read their own slot of `STRATEGY_DEFINITIONS[strategy].identityTendencies` (P1 variety wave, task 3 — before this, no strategy's `layoutTendencies` ever named a non-content id, so cover/chapter/ending were structurally unweighted, uniform sampling — identity pages now get the same soft-weight treatment content pages have had since W4, via their own disjoint id namespace). Either source's tendency-set members get `TENDENCY_WEIGHT` (×3), everything else `BASE_WEIGHT` (×1) — both constants in `layout-selection.ts`, explicitly *not yet tuned* against a real corpus. A slide's optional `beat` (P1 variety wave, task 1 — `Slide.beat`, `src/ir/index.ts`) contributes a second, independent ×3/×1 weight via `BEAT_TENDENCIES`/`BEAT_TENDENCY_WEIGHT`/`BEAT_BASE_WEIGHT` (`layout-selection.ts`) — `BEAT_TENDENCIES` only ever names content-layout ids, so beat stays a structural no-op for cover/chapter/ending regardless of slide type. A theme's own `layoutTendencies[slideType]` (`ThemeDefinition.layoutTendencies`, `src/themes/definitions.ts` — theme-structure wave) contributes a third, independent ×3/×1 weight, resolved by the caller (`resolveOneEffectiveLayoutId`) and passed into `resolveLayoutId` as `themeTendencies` — it reuses `TENDENCY_WEIGHT`/`BASE_WEIGHT` rather than a third named constant pair, and unlike beat it can name ids for *any* of the four slide types, cover/chapter/ending included: **all 17 built-ins declare it** as of the theme-structure-allocation wave, each choosing the layout family that is that theme's own native register (e.g. consulting's `banner-title`/`banner-chapter`/`banner-ending` — verbatim extractions of its own predecessor render code; vermilion's `banner-chapter`/`rail-chapter`/`rail-ending` — the 庄重 red section divider + 条理 progress rail + structured contact close of its official-report register). Every one of the 17 declares a cover set; chapter and ending stay partial by design, since a theme with nothing to say on an axis is better off silent than filling the table (see `terra`'s and `ink`'s own comments in `definitions.ts`). `bloom` and `classroom` point at the *same object* (`CLASSROOM_STRUCTURE`), a declared recolor rather than a missed declaration, so the 17 theme ids resolve to 16 distinct structural identities. All three sources combine via **`Math.max`, not multiplication** (P1 fix round's original ruling over strategy/beat, extended to the theme layer by the theme-structure wave's own controller ruling, `.issues/2026-07-26-theme-structure/plan.md` 控制器设计裁定 2): a product measurably compounded whenever a strategy's own tendency set and a beat's tendency set agreed on the same layout — storytelling × beat "breathing" both already favor `narrow-column`, which squared into ~53% realized share for that one layout, N=5000 — the exact monotony this mechanism exists to prevent. All three layers assert the same underlying fitness dimension — "which candidate should this slide favor" — not orthogonal ones whose signals should stack, so `max` lets any two (or all three) agreeing corroborate a candidate without squaring the pull, while disagreement still lets each layer's own weight through unreduced. An omitted `beat` and/or an undeclared (or slide-type-absent) theme `layoutTendencies` each contribute an implicit weight of 1 for every candidate, which `max` never lets exceed the strategy-only weight — so a deck that never declares `beat`, rendered on a page type its theme leaves undeclared, selects and renders byte-identically to before either layer existed. A second, subtler no-op is worth knowing when declaring: because the layers compose via `Math.max`, a theme tendency naming only ids the active strategy already favors adds nothing under that strategy (`max(3,3) = 3`) — a declaration that reads fine on paper and is invisible in the render. The allocation table shipped two of these on the cover axis (`insight` and `vermilion` were each assigned exactly `briefing.identityTendencies.cover`) before anyone checked the table against this rule; both are corrected, and `theme-structure.test.ts` now holds every structural identity to naming at least one cover id **briefing** does not already favor. Briefing specifically, because that is what a deck naming no narrative resolves to, so it is the one strategy where an inert declaration is invisible to someone who never opted into anything. `weightedPickBySeed` (`src/svg/variety.ts:69-90`) samples against the salt `` `${slideType}-archetype:${pageKey}` ``. Note `general`'s default axes resolve to strategy `briefing`, and `briefing` carries a real (non-empty) `identityTendencies` set like every other strategy — an omitted-narrative deck's identity-page picks are *not* uniform/tendency-free either, mirroring how `briefing`'s content `layoutTendencies` has never been tendency-free since W4. Measured effect of the theme layer: a fixed multi-page IR and a fixed seed, rendered under all 13 themes, went from 1 distinct per-page layout sequence (the diagnosed defect that wave fixed — every theme picking identically because `theme.layouts` curates the full set for all 13 and, until then, no theme ever weighted within it) to 7 distinct sequences. Measured again on the cover slot alone, across the 16 structural identities on a fixed deck at seeds 1-40: **8 distinct cover sequences before the theme-structure-allocation wave, 10 after it, 9 once the two inert declarations above were corrected**. The last step is a drop for a good reason — one of those 10 groups was the two themes whose declarations did nothing, so it counted as a distinct behaviour while being the absence of one. The number that matters alongside it: identities picking their cover exactly the way an undeclared theme does went 8 of 16 → 2 → **0**. `src/themes/theme-structure.test.ts` holds every one of these. **Adjacent anti-repetition** runs after: if the pick equals the immediately preceding slide's own resolved layout id and the pool has >1 member, redraw once against the same salt with that id removed (deterministic runner-up, local — it never touches any other page). This replaced an earlier same-type-ordinal rotation scheme (`pickBySeedRotating`, since deleted) that reshuffled non-adjacent pages on insert/reorder. Note the collapse from an original 5-step design: content-fit filtering (candidate must physically fit the content) is **not** a selection-pool filter. Capacity is a `validate`-time `min(editorial budget, geometric capacity)` gate (`docs/concepts.md`'s capacity section) — selection never reads slide content, so editing a page's components can never flip its layout. Deliberate, not an oversight — see `layout-selection.ts`'s header comment. ## Seed: a three-tier ladder for revision stability Deterministic selection alone (same input → same output) isn't enough for a *revision* workflow — editing one page's heading must not reshuffle every other page's auto-pick. `deckSeed`/`cachedDeckSeed` (`src/svg/variety.ts:22-50`) resolve, in order: 1. **Explicit `ir.seed`** — wins outright, stable across any edit. 2. **Deck-project-derived**: `assembleDeck` (`src/spec/assemble.ts:115-140`, `generateSeed`/`stableHash`) djb2-hashes `filename + the spec's ordered page-id sequence` — deliberately *not* heading/component content — when the spec omits `seed`. Written into `ir.seed` and returned as `generatedSeed` — the CLI shell surfaces it for the caller to copy back into `deck.spec.json`. 3. **Bare-IR fallback**: a content hash of `filename` + every slide's `heading` — legacy-compatible, but editing any heading reshuffles the whole deck's auto-picks. Documented cost of skipping steps 1-2, not a bug. Each page's own salt uses its stable `page id` (`slide.id ?? String(index)`), not a same-type ordinal — insert/reorder no longer perturbs unrelated pages' sampling. ## Materialization `assembleDeck`'s `materializeEffectiveLayouts` (`src/spec/assemble.ts:347`) runs `resolveEffectiveLayoutId` once per omitted-`layout` slide after the IR is fully schema-defaulted, writing the auto-pick straight into `deck.json`'s `layout` field — a page file's explicit pin is left untouched. This is why re-assembling with a stable seed reproduces the same picks: the deck-wide fold (`resolveDeckEffectiveLayoutIds`, `layout-selection.ts:455`) is memoized per `ir` object identity and walks the deck exactly once, in order, so adjacent anti-repetition sees each slide's *final* resolved id, not a partially-materialized one.