From a94331baf02cdb18d5f767a2b08501068b3e3290 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 29 Jul 2026 16:45:20 -0700 Subject: [PATCH] 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) --- skill/scripts/concept-seed.mjs | 38 +++++-- skill/scripts/lib/composition-catalog.mjs | 33 ++++++ skill/scripts/lib/concept-catalog.mjs | 28 +++++ skill/scripts/lib/roll-selection.mjs | 39 ++++++- tests/concept-seed.test.mjs | 120 ++++++++++++++++++++++ 5 files changed, 246 insertions(+), 12 deletions(-) diff --git a/skill/scripts/concept-seed.mjs b/skill/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/skill/scripts/concept-seed.mjs +++ b/skill/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur const scopeIdx = args.indexOf('--scope'); const rerollIdx = args.indexOf('--reroll'); const modeIdx = args.indexOf('--mode'); + const areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur : (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')), reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0, mode: modeIdx !== -1 ? args[modeIdx + 1] : null, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/skill/scripts/lib/composition-catalog.mjs b/skill/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/skill/scripts/lib/composition-catalog.mjs +++ b/skill/scripts/lib/composition-catalog.mjs @@ -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())) { diff --git a/skill/scripts/lib/concept-catalog.mjs b/skill/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/skill/scripts/lib/concept-catalog.mjs +++ b/skill/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/skill/scripts/lib/roll-selection.mjs b/skill/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/skill/scripts/lib/roll-selection.mjs +++ b/skill/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/tests/concept-seed.test.mjs b/tests/concept-seed.test.mjs index dcbe43d22..916868987 100644 --- a/tests/concept-seed.test.mjs +++ b/tests/concept-seed.test.mjs @@ -465,4 +465,124 @@ describe('init gate', () => { assert.equal(result.status, 0); assert.doesNotMatch(result.stdout, /NO_PRODUCT_MD/); }); + + // Mode eligibility on worlds. Before this, selectApprovedChallengers never + // received the mode at all, so a build asking for an app UI could draw six + // worlds that only make sense on a landing page. + it('keeps worlds out of modes their reviewer excluded, and treats absent as all modes', () => { + const make = (id, tier, allowedModes) => ({ + id, + familyId: `${id}-family`, + wellTier: tier, + strength: 'world', + status: 'approved', + form: `${id} form`, + spark: `${id} spark`, + system: [], + webLeverage: `${id} web`, + review: { status: 'approved', ...(allowedModes ? { allowedModes } : {}) }, + }); + const pool = [ + make('persuade-only', 'graphic', ['persuade']), + make('anywhere', 'graphic'), + make('operate-capable', 'graphic', ['operate', 'read']), + make('radar', 'interaction'), + make('cavern', 'atmosphere'), + ]; + + for (let index = 0; index < 25; index += 1) { + const operate = selectApprovedChallengers({ + scope: 'direction', key: `mode-gate-${index}`, mode: 'operate', sourceConcepts: pool, + }).picks.map(pick => pick.id); + assert.equal(operate.includes('persuade-only'), false, `persuade-only dealt for operate at ${index}`); + } + + // Absent allowedModes stays eligible in every mode. + const seen = new Set(); + for (let index = 0; index < 25; index += 1) { + for (const mode of ['persuade', 'operate', 'read', 'experience']) { + for (const pick of selectApprovedChallengers({ + scope: 'direction', key: `mode-any-${index}`, mode, sourceConcepts: pool, + }).picks) seen.add(pick.id); + } + } + assert.equal(seen.has('anywhere'), true, 'a world with no allowedModes must stay eligible'); + }); + + it('falls back rather than starving a tier whose every world excludes the mode', () => { + const make = (id, tier, allowedModes) => ({ + id, + familyId: `${id}-family`, + wellTier: tier, + strength: 'world', + status: 'approved', + form: `${id} form`, + spark: `${id} spark`, + system: [], + webLeverage: `${id} web`, + review: { status: 'approved', ...(allowedModes ? { allowedModes } : {}) }, + }); + // The whole interaction tier is persuade-only. Selection must degrade to it + // rather than throw or deal fewer than six. + const pool = [ + make('graphic-any', 'graphic'), + make('graphic-two', 'graphic'), + make('radar-persuade', 'interaction', ['persuade']), + make('cavern-any', 'atmosphere'), + ]; + const picks = selectApprovedChallengers({ + scope: 'direction', key: 'starve', mode: 'operate', sourceConcepts: pool, + }).picks; + assert.equal(picks.some(pick => pick.id === 'radar-persuade'), true, 'an emptied tier must fall back to its full pool'); + }); + + // Areas of concern under a surface. Surface alone is too coarse: an onboarding + // flow and a settings page are both operate and want different compositions. + it('prefers compositions in the requested area and tops up from the surface', () => { + const make = (id, area) => ({ + id, + familyId: `${id}-family`, + surface: 'operate', + status: 'approved', + ...(area ? { area } : {}), + review: { status: 'approved' }, + }); + const pool = [ + make('onboard-one', 'onboarding-and-setup'), + make('onboard-two', 'onboarding-and-setup'), + make('settings-one', 'settings-and-account'), + make('dash-one', 'dashboard-and-overview'), + make('untagged', null), + ]; + const picks = selectApprovedCompositions({ + scope: 'surface', key: 'area-pref', mode: 'operate', area: 'onboarding-and-setup', sourceCompositions: pool, + }); + assert.equal(picks.length, 3, 'a thin area must top up rather than deal fewer'); + const ids = picks.map(pick => pick.id); + assert.equal(ids.includes('onboard-one') && ids.includes('onboard-two'), true, 'both area matches must be dealt first'); + + // Without an area the deal is unchanged, which is what keeps this additive. + const plain = selectApprovedCompositions({ + scope: 'surface', key: 'area-pref', mode: 'operate', sourceCompositions: pool, + }); + assert.equal(plain.length, 3); + }); + + it('reproduces an area-scoped deal from the same key', () => { + const make = (id, area) => ({ + id, familyId: `${id}-family`, surface: 'read', status: 'approved', + ...(area ? { area } : {}), review: { status: 'approved' }, + }); + const pool = [ + make('article-one', 'long-form-article'), + make('article-two', 'long-form-article'), + make('docs-one', 'reference-and-docs'), + make('index-one', 'index-and-archive'), + ]; + const args = { scope: 'surface', key: 'area-stable', mode: 'read', area: 'long-form-article', sourceCompositions: pool }; + assert.deepEqual( + selectApprovedCompositions(args).map(pick => pick.id), + selectApprovedCompositions(args).map(pick => pick.id) + ); + }); });