Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-07-30 00:37:20 +00:00
parent 86b91a2003
commit fcba5370f0
45 changed files with 2595 additions and 1020 deletions
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
+66 -26
View File
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}
@@ -38,16 +38,22 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --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.
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
* preference: it deals matching compositions first and tops up from the rest of
* the register, and the rendered seed says how many actually matched so a
* borrowed structure is never mistaken for a supplied one.
*
* --platform names the delivery target (web, ios, android). Unlike grain this is
* a hard filter: a composition that needs hover or a pointer does not degrade on
* a phone, it stops working. --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,
@@ -75,8 +81,10 @@ import {
validateConceptCatalog,
WELL_TIERS,
} from './lib/concept-catalog.mjs';
import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs';
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
COMPOSITION_GRAINS,
COMPOSITION_PLATFORMS,
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
@@ -134,10 +142,11 @@ function requireLocalConcepts() {
return local;
}
async function fetchRoll({ scope, key, mode, area, reroll }) {
async function fetchRoll({ scope, key, mode, grain, platform, reroll }) {
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
if (mode) params.set('mode', mode);
if (area) params.set('area', area);
if (grain) params.set('grain', grain);
if (platform) params.set('platform', platform);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
@@ -218,9 +227,15 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) {
export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count }));
}
// Array-returning form, which is what every caller wanted before the match
// report existed.
export function selectApprovedCompositions(options) {
return dealCompositions(options).picks;
}
// Compatibility for callers that need a single smoke-test sample.
@@ -246,7 +261,8 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
area = null,
grain = null,
platform = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
@@ -260,14 +276,13 @@ 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(', ')})`);
}
// Grain needs no mode: how much of the product is in play is independent of
// which register of work it is.
if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) {
throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) {
throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`);
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
@@ -299,17 +314,21 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }),
...(() => {
const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions });
return { compositions: dealt.picks, compositionMatch: dealt.match };
})(),
};
} 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, area, reroll }).then(roll => renderConceptSeed({
return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({
scope,
key,
reroll,
mode,
area,
grain,
platform,
candidateCount,
catalogDir,
_resolvedData: roll ? {
@@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}.
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
// The grain report. A top-up keeps the deal at three, which is right, but it
// must not read as three on-target inputs: a flow request answered entirely by
// view-grain compositions means the model has to derive the flow's own
// structure and borrow only their sequence law. Silence here would reproduce
// the exact failure this axis exists to fix.
const match = data.compositionMatch ?? null;
const grainNote = (() => {
if (!match?.grain) return '';
if (match.grainAvailable === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`;
}
if (match.atGrain === 0) {
return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`;
}
if (match.atGrain < compositions.length) {
return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`;
}
return '';
})();
const compositionBlock = compositions.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')}
Each one asks the same question of this build: what is the cleverest way to
present, organize, or make interactive the problem in front of you? They carry
structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.\n`
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
@@ -493,7 +532,8 @@ 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,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ 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(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)
@@ -21,27 +21,44 @@
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// 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.
// Grain: how much of the product a composition composes. Named grain rather than
// scope because scope already means direction-or-surface on every roll, and
// 'surface' is already a register value, so a scope of 'surface' would collide
// with both.
//
// `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'],
};
// This axis is framed by what the skill can be asked for, not by what the
// catalog happens to hold. A user asks for a docs site, an onboarding flow, a
// landing page, or a data table, and those are four different amounts of
// product. Register says what kind of work it is; grain says how much of it.
// Without grain, a request for a hero section can be dealt a whole-site
// navigation structure and nothing notices.
//
// Measured when this was added: 137 of 173 approved compositions were view
// grain, product grain was empty, and flow grain held one entry. That is why an
// onboarding request had nothing to draw.
export const COMPOSITION_GRAINS = [
'product', // a whole site or app: its information architecture
'flow', // a sequence of views with one outcome: onboarding, checkout, setup
'view', // one page or screen
'region', // a section inside a view: a hero, a feature grid, a table
];
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
// Delivery targets a composition can survive. Mirrors the skill's platform axis
// minus 'adaptive', which is a project-level value meaning both native targets
// rather than something a single composition is authored for.
//
// A composition that leans on hover, a pointer, or a wide viewport does not
// survive a phone, and nothing in the schema could say so before this.
export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android'];
export function areasForSurface(surface) {
return COMPOSITION_AREAS[surface] ?? [];
// Both fields are optional and absence means eligible everywhere, so no entry
// has to be backfilled before this ships and no existing roll changes.
export function isGrain(value) {
return COMPOSITION_GRAINS.includes(value);
}
export function isPlatform(value) {
return COMPOSITION_PLATFORMS.includes(value);
}
@@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
return { approved, picks };
}
function emptyMatch(grain, platform, platformExcluded = 0) {
return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded };
}
/**
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
@@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play
* @param {string|null} [options.platform] delivery target the result has to survive
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
* @returns {Generator<string[], {picks: Array, match: object}, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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
@@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
if (approved.length === 0) return [];
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return [];
if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) };
approved = matching;
}
// Platform is a hard filter, unlike grain. A composition that needs hover or a
// pointer does not degrade on a phone into something slightly worse; it stops
// working, so borrowing it would be a defect rather than a stretch. Absent
// platforms means it survives anywhere.
let platformExcluded = 0;
if (platform) {
const survives = approved.filter(composition => {
const only = composition.platforms;
return !Array.isArray(only) || only.length === 0 || only.includes(platform);
});
platformExcluded = approved.length - survives.length;
// No fallback here either: dealing a hover-only composition to a phone build
// is worse than dealing nothing, and an empty deal is a visible gap.
approved = survives;
if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) };
}
const prior = new Set();
let picks = [];
@@ -278,14 +316,18 @@ 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
// Grain is a preference, not a filter: requesting an onboarding flow deals
// flow-grain compositions first and tops up from the rest of the register
// rather than dealing fewer than three. 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)]
//
// The top-up is why match is reported. Dealing three plausible view-grain
// compositions against a flow request, with no signal that none matched, is
// the same silent-plausibility failure this whole axis exists to fix: the
// model would improvise the flow structure while believing it was handed one.
const ordered = grain
? [...ranked.filter(composition => composition.grain === grain),
...ranked.filter(composition => composition.grain !== grain)]
: ranked;
const families = new Set();
@@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
}
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
}
return picks;
const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null;
return {
picks,
match: {
grain: grain ?? null,
// How many of the dealt compositions actually sit at the requested grain.
// 0 with a grain requested means every pick is a borrowed structure.
atGrain,
grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null,
platform: platform ?? null,
platformExcluded,
},
};
}