Add the mode and area axes to the roll

Two gaps, both reported from real use. Worlds were drawn with no mode
awareness at all: selectApprovedChallengers never received the mode, so a
build asking for an app UI could draw six worlds that only make sense on a
landing page. And surface alone is too coarse for compositions, because
"operate" spans onboarding, dashboards, editors and settings, so an
onboarding flow could legitimately be dealt a settings composition.

Worlds gain `allowedModes` on the review record, beside breadth and rating,
because it is a reviewer judgment rather than authored content. Absent means
eligible in every mode, so nothing needs backfilling and no existing roll
changes. Applied per tier and skipped where it would empty one, matching how
minRating and strength already degrade. It is a ceiling the reviewer lowers,
not a category they assign: a world is an identity, and identities transfer
across modes further than compositions do.

Compositions gain an optional `area`, one level below surface, with a
taxonomy per surface (COMPOSITION_AREAS). Area is a preference rather than a
filter: a request reorders the ranking to put area matches first and tops up
from the rest of the surface, because the per-area pools are small and
dealing one on-target composition would be worse than three good ones. A
stable partition of an already deterministic ranking stays deterministic.

`--area` on the CLI requires `--mode`, since areas are scoped to a surface,
and is validated against that surface's list so a wrong-surface area fails
loudly instead of silently matching nothing.

Also validated `breadth`, which selection has honoured for a while with
nothing checking it, so a typo read as "general" and quietly returned a
narrow world to the pool.

Four new tests: worlds excluded from a mode stay out, absent allowedModes
stays eligible everywhere, a tier whose every world excludes the mode falls
back instead of starving, and an area-scoped deal prefers its area, tops up
to three, and reproduces from its key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-29 16:45:20 -07:00
co-authored by Claude Opus 5
parent 7c32d42055
commit a94331baf0
5 changed files with 246 additions and 12 deletions
+33
View File
@@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [
// composition are different species, and read/experience surfaces get their own.
export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']);
// Areas of concern, one level below surface. Surface alone is too coarse to deal
// against: "operate" spans onboarding, settings, dashboards and editors, so a
// build designing an onboarding flow could legitimately draw a settings
// composition and the input would read as noise. Areas name the problem the
// composition is about, not how it is built, which is what familyId already does.
//
// `area` is optional. An entry without one is eligible for any request in its
// surface, so nothing has to be backfilled before this ships, and a request for
// an area with a thin pool tops up from the rest of the surface rather than
// dealing fewer.
export const COMPOSITION_AREAS = {
persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'],
operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'],
read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'],
experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'],
};
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
}
export function compositionContentHash(composition) {
const payload = [
composition?.form ?? '',
@@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map(
if (!COMPOSITION_SURFACES.has(composition?.surface)) {
errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`);
}
// Optional, but an area from the wrong surface is a mistake rather than a
// looser tag: it would make the entry unreachable by every real request.
if (composition?.area !== undefined && composition.area !== null) {
const allowed = areasForSurface(composition.surface);
if (!allowed.includes(composition.area)) {
errors.push(
`composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})`
);
}
}
if (!Array.isArray(composition?.tags)
|| composition.tags.length !== 3
|| composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {