From 86b91a20039e342e02ce037d59e6cdb019fe4305 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 29 Jul 2026 17:36:43 -0700 Subject: [PATCH] Replace the invented area axis with grain and platform The area taxonomy was wrong, and wrong in a way worth recording. It was derived from Mobbin-style categories in the abstract rather than from what the skill can be asked for, and measured against the catalog most of it described problems that were not there: onboarding, settings, empty-state and search each had zero entries. Reframed against demand instead. A user asks for a docs site, an onboarding flow, a landing page, or a data table, and those differ in how much of the product is in play. Register already says what kind of work it is; grain says how much: product, flow, view, region. 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 have collided with both. Platform is the second axis: web, ios, android. Unlike grain it is a hard filter with no fallback, because a composition that leans on hover or a pointer does not degrade on a phone into something slightly worse, it stops working, and an empty deal is a visible gap where a broken one is not. Both fields are optional and absence means eligible everywhere, so nothing needs backfilling and no existing roll changes. The third piece is the one a trace turned up. Asking for an onboarding flow resolves to register=operate, grain=flow, and the catalog holds zero flow-grain compositions, so the top-up would have dealt three plausible single-screen compositions with no signal that none matched. The model would have improvised the flow structure while believing it was handed one, which is the same silent plausibility the axis exists to remove. Selection now returns a match alongside the picks, and the rendered seed says when the structure is borrowed and why. Measured at the time of writing: 137 of 173 approved compositions are view grain, product grain is empty, flow grain holds one. Co-Authored-By: Claude Opus 5 (1M context) --- skill/scripts/concept-seed.mjs | 92 ++++++++++++----- skill/scripts/lib/composition-catalog.mjs | 32 ++++-- skill/scripts/lib/roll-selection.mjs | 117 ++++++++++++++++------ tests/concept-seed.test.mjs | 96 ++++++++++-------- 4 files changed, 229 insertions(+), 108 deletions(-) diff --git a/skill/scripts/concept-seed.mjs b/skill/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/skill/scripts/concept-seed.mjs +++ b/skill/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from - * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup + * node scripts/concept-seed.mjs --scope 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 --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * - * --area names the area of concern inside that mode (an onboarding flow and a - * settings page are both operate, and want different compositions). It needs - * --mode, prefers compositions in that area, and tops up from the rest of the - * surface rather than dealing fewer. --mode also gates which worlds are - * eligible, for worlds whose reviewer marked them as carrying only some modes. + * --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, })); } diff --git a/skill/scripts/lib/composition-catalog.mjs b/skill/scripts/lib/composition-catalog.mjs index d0a6d4cc8..16378187e 100644 --- a/skill/scripts/lib/composition-catalog.mjs +++ b/skill/scripts/lib/composition-catalog.mjs @@ -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) diff --git a/skill/scripts/lib/roll-selection.mjs b/skill/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/skill/scripts/lib/roll-selection.mjs +++ b/skill/scripts/lib/roll-selection.mjs @@ -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} + * @returns {Generator} */ -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, + }, + }; } diff --git a/tests/concept-seed.test.mjs b/tests/concept-seed.test.mjs index 916868987..cc94738de 100644 --- a/tests/concept-seed.test.mjs +++ b/tests/concept-seed.test.mjs @@ -11,7 +11,7 @@ import { validateConceptEntry, } from '../skill/scripts/lib/concept-catalog.mjs'; import { readCompositionCatalog } from '../skill/scripts/lib/composition-catalog.mjs'; -import { renderChallenger, selectApprovedChallengers, selectApprovedComposition, selectApprovedCompositions } from '../skill/scripts/concept-seed.mjs'; +import { dealCompositions, renderChallenger, selectApprovedChallengers, selectApprovedComposition, selectApprovedCompositions } from '../skill/scripts/concept-seed.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const SCRIPT = path.join(ROOT, 'skill', 'scripts', 'concept-seed.mjs'); @@ -536,53 +536,69 @@ describe('init gate', () => { assert.equal(picks.some(pick => pick.id === 'radar-persuade'), true, 'an emptied tier must fall back to its full pool'); }); - // Areas of concern under a surface. Surface alone is too coarse: an onboarding - // flow and a settings page are both operate and want different compositions. - it('prefers compositions in the requested area and tops up from the surface', () => { - const make = (id, area) => ({ - id, - familyId: `${id}-family`, - surface: 'operate', - status: 'approved', - ...(area ? { area } : {}), - review: { status: 'approved' }, + // Grain: how much of the product a composition composes. Framed by what the + // skill can be asked for (a docs site, an onboarding flow, a landing page, a + // data table) rather than by what the catalog happens to hold. + it('prefers the requested grain and tops up from the register', () => { + const make = (id, grain) => ({ + id, familyId: `${id}-family`, surface: 'operate', status: 'approved', + ...(grain ? { grain } : {}), review: { status: 'approved' }, }); const pool = [ - make('onboard-one', 'onboarding-and-setup'), - make('onboard-two', 'onboarding-and-setup'), - make('settings-one', 'settings-and-account'), - make('dash-one', 'dashboard-and-overview'), + make('flow-one', 'flow'), + make('flow-two', 'flow'), + make('view-one', 'view'), + make('view-two', 'view'), make('untagged', null), ]; - const picks = selectApprovedCompositions({ - scope: 'surface', key: 'area-pref', mode: 'operate', area: 'onboarding-and-setup', sourceCompositions: pool, - }); - assert.equal(picks.length, 3, 'a thin area must top up rather than deal fewer'); - const ids = picks.map(pick => pick.id); - assert.equal(ids.includes('onboard-one') && ids.includes('onboard-two'), true, 'both area matches must be dealt first'); - - // Without an area the deal is unchanged, which is what keeps this additive. - const plain = selectApprovedCompositions({ - scope: 'surface', key: 'area-pref', mode: 'operate', sourceCompositions: pool, - }); - assert.equal(plain.length, 3); + const dealt = dealCompositions({ scope: 'surface', key: 'grain-pref', mode: 'operate', grain: 'flow', sourceCompositions: pool }); + assert.equal(dealt.picks.length, 3, 'a thin grain tops up rather than dealing fewer'); + const ids = dealt.picks.map(pick => pick.id); + assert.equal(ids.includes('flow-one') && ids.includes('flow-two'), true, 'both grain matches deal first'); + assert.equal(dealt.match.atGrain, 2); + assert.equal(dealt.match.grainAvailable, 2); }); - it('reproduces an area-scoped deal from the same key', () => { - const make = (id, area) => ({ - id, familyId: `${id}-family`, surface: 'read', status: 'approved', - ...(area ? { area } : {}), review: { status: 'approved' }, + // The report is the point. Three plausible view-grain compositions dealt + // against a flow request, with no signal that none matched, is the same silent + // plausibility this axis exists to remove. + it('reports a grain miss instead of passing off borrowed structure', () => { + const make = id => ({ id, familyId: `${id}-family`, surface: 'operate', status: 'approved', grain: 'view', review: { status: 'approved' } }); + const pool = [make('a'), make('b'), make('c')]; + const dealt = dealCompositions({ scope: 'surface', key: 'grain-miss', mode: 'operate', grain: 'flow', sourceCompositions: pool }); + assert.equal(dealt.picks.length, 3, 'still deals three'); + assert.equal(dealt.match.atGrain, 0, 'and says none matched'); + assert.equal(dealt.match.grainAvailable, 0); + }); + + // Platform is a hard filter, not a preference: a composition that needs hover + // does not degrade on a phone, it stops working. + it('excludes compositions the platform cannot carry, with no fallback', () => { + const make = (id, platforms) => ({ + id, familyId: `${id}-family`, surface: 'operate', status: 'approved', + ...(platforms ? { platforms } : {}), review: { status: 'approved' }, }); - const pool = [ - make('article-one', 'long-form-article'), - make('article-two', 'long-form-article'), - make('docs-one', 'reference-and-docs'), - make('index-one', 'index-and-archive'), - ]; - const args = { scope: 'surface', key: 'area-stable', mode: 'read', area: 'long-form-article', sourceCompositions: pool }; + const pool = [make('web-only', ['web']), make('anywhere', null), make('native', ['ios', 'android'])]; + const onIos = dealCompositions({ scope: 'surface', key: 'plat', mode: 'operate', platform: 'ios', sourceCompositions: pool }); + const ids = onIos.picks.map(pick => pick.id); + assert.equal(ids.includes('web-only'), false, 'a web-only composition must not reach an iOS build'); + assert.equal(onIos.match.platformExcluded, 1); + + const allWebOnly = [make('x', ['web']), make('y', ['web'])]; + const starved = dealCompositions({ scope: 'surface', key: 'plat2', mode: 'operate', platform: 'android', sourceCompositions: allWebOnly }); + assert.deepEqual(starved.picks, [], 'an empty deal beats dealing something that cannot work'); + }); + + it('reproduces a grain-scoped deal from the same key', () => { + const make = (id, grain) => ({ + id, familyId: `${id}-family`, surface: 'read', status: 'approved', + ...(grain ? { grain } : {}), review: { status: 'approved' }, + }); + const pool = [make('p1', 'product'), make('v1', 'view'), make('v2', 'view'), make('r1', 'region')]; + const args = { scope: 'surface', key: 'grain-stable', mode: 'read', grain: 'product', sourceCompositions: pool }; assert.deepEqual( - selectApprovedCompositions(args).map(pick => pick.id), - selectApprovedCompositions(args).map(pick => pick.id) + selectApprovedCompositions(args).map(p => p.id), + selectApprovedCompositions(args).map(p => p.id) ); }); });