Call them compositions; single-source WELL_TIERS

The data layer has said compositions since the catalog was split, while the
code, the model-facing text, and the UI still said stagings. The rename was
held back by the selection logic existing twice; it exists once now, so this
is one pass instead of two coordinated ones.

Renamed: selectApprovedStagings, selectApprovedStaging, renderStaging, and
the model-facing STAGING GRAMMAR / STAGING CHALLENGERS / FIRST-SURFACE
STAGING INPUTS headings. The block that introduces them now states what
they are for rather than only what they are not: what is the cleverest way
to present, organize, or make interactive the problem in front of you.

Three places keep the old word on purpose:

- The rank salt, `${scope}:${key}:staging`. It is hash input, so renaming
  it would re-deal every roll anyone has ever reproduced by key. Verified:
  240 seeder rolls and 252 API rolls reproduce exactly.
- `Staging/hierarchy:`, the first composition grammar prefix. Inside a
  composition, staging names one of its four aspects, which is a different
  word-sense from staging as the name for the whole artifact. It is also a
  schema constant that 317 catalog entries are validated against.
- The wire fields. The API keeps emitting `stagings` and `staging` beside
  `compositions`, because the wire is the one place a rename cannot be
  coordinated with already-installed skills. Clients prefer the new field
  and fall back through both old ones.

Separately, WELL_TIERS had two definitions after the extraction.
roll-selection.mjs owns it now and concept-catalog.mjs imports it, in that
direction because concept-catalog reads the filesystem and a Pages Function
must not pull node:fs into its bundle. Imported and re-exported rather than
re-exported alone: a bare `export { X } from` does not bind X locally, and
validateConceptCatalog needs it, which cost one round of red tests.

Dropped concept-catalog's synchronous deterministicRank. Nothing imports it
since selection moved out, and leaving a second ranking implementation
around is how the first drift started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-29 16:26:46 -07:00
co-authored by Claude Opus 5
parent 71d7b5e310
commit a92ba5f2b0
5 changed files with 74 additions and 62 deletions
+32 -22
View File
@@ -29,7 +29,7 @@
* win over thin categories, which is the intended shape.
* - RE-ROLL (--reroll <n>): round n of the same base key. The script
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
* fresh assigned index, challengers, and staging. One base key therefore
* fresh assigned index, challengers, and compositions. One base key therefore
* reproduces the entire chain of rounds.
* - RATINGS: the reviewer's approval ratings weight the challenger draw
* (3-star doubles the odds, 1-star sits out); the approved pool itself
@@ -43,8 +43,8 @@
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
*
* --mode names the requested surface's mode (persuade, operate, read,
* experience) so the appended staging matches its register of work; omitted,
* the staging rolls from the full approved pool.
* experience) so the appended compositions match its register of work; omitted,
* they roll from the full approved pool.
*
* Challenger data resolves in order: a local catalog directory (the private
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
@@ -72,7 +72,7 @@ import { readCompositionCatalog } from './lib/composition-catalog.mjs';
import {
runSyncSelection,
selectApprovedChallengers as selectApprovedChallengersCore,
selectApprovedStagings as selectApprovedStagingsCore,
selectApprovedCompositions as selectApprovedCompositionsCore,
} from './lib/roll-selection.mjs';
const here = dirname(fileURLToPath(import.meta.url));
@@ -191,12 +191,12 @@ ${system}
QUALITY BAR: board ${board} · hero ${hero}`;
}
export function renderStaging(composition, index = null) {
export function renderComposition(composition, index = null) {
const grammar = composition.grammar.map(rule => ` - ${rule}`).join('\n');
return ` ${index == null ? '' : `${index + 1}. `}${composition.form}
SOURCE ID: ${composition.id}
SPARK: ${composition.spark}
STAGING GRAMMAR:
COMPOSITION GRAMMAR:
${grammar}
WEB LEVERAGE: ${composition.webLeverage}`;
}
@@ -210,14 +210,14 @@ function driveSelection(generator) {
return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex'));
}
export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) {
export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) {
const compositions = sourceCompositions ?? requireLocalConcepts().compositions;
return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count }));
return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count }));
}
// Compatibility for callers that need a single smoke-test sample.
export function selectApprovedStaging(options) {
return selectApprovedStagings({ ...options, count: 1 })[0] ?? null;
export function selectApprovedComposition(options) {
return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null;
}
export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) {
@@ -280,7 +280,7 @@ export function renderConceptSeed({
approvedCount: approved.length,
catalogCount,
challengers: picks,
stagings: selectApprovedStagings({ scope, key, reroll, mode, sourceCompositions: local.compositions }),
compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }),
};
} else {
// Keep local renders synchronous for prepared eval sessions and tests;
@@ -298,7 +298,11 @@ export function renderConceptSeed({
approvedCount: roll.approvedCount,
catalogCount: roll.catalogCount,
challengers: roll.challengers,
stagings: Array.isArray(roll.stagings) ? roll.stagings : roll.staging ? [roll.staging] : [],
compositions: Array.isArray(roll.compositions)
? roll.compositions
: Array.isArray(roll.stagings)
? roll.stagings
: roll.staging ? [roll.staging] : [],
} : null,
}));
}
@@ -381,15 +385,21 @@ ${buildIndex} of your own grounded list; seed key ${key}.
`;
}
const stagings = Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
const stagingBlock = stagings.length > 0
? `\n${scope === 'direction' ? 'FIRST-SURFACE STAGING INPUTS (identity-free; test them with shortlisted worlds and keep world plus staging one decision):' : 'STAGING CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'}
${stagings.map((staging, index) => renderStaging(staging, index)).join('\n')}
Stagings organize attention, sequence, and manipulation; they never bring a
palette, typeface, or material. Use them as serious alternatives to the model's
habitual composition, but keep only structures that strengthen this product.\n`
// Field order is the migration: `compositions` is current, `stagings` is what
// the API emitted while these were called stagings, and `staging` is the
// single-pick shape from before it dealt three. Older installs keep working.
const compositions = Array.isArray(data.compositions)
? data.compositions
: Array.isArray(data.stagings)
? data.stagings
: data.staging ? [data.staging] : [];
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`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
@@ -410,7 +420,7 @@ ${rerollBlock}ASSIGNED INDEX: ${buildIndex}
the user or the brief. Never expose assignment metadata in user-facing labels.
CHALLENGERS:
${data.challengers.map(renderChallenger).join('\n')}
${stagingBlock}${challengerInstruction}
${compositionBlock}${challengerInstruction}
When you can view images, open the QUALITY BAR board and hero for any
challenger you weigh seriously and for the world you build. They exist as a
craft bar, the finish level and commitment the build is expected to reach,
+3 -3
View File
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Catalog B: stagings rather than styles. A composition organizes attention,
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
// any committed visual identity; it deliberately carries no palette or type
// half. Surface-scope seeds draw from here (plus catalog A duals); direction
@@ -15,8 +15,8 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [
'Adaptation:',
];
// Surfaces align with the skill's modes: a persuade staging and an operate
// staging are different species, and read/experience surfaces get their own.
// Surfaces align with the skill's modes: a persuade composition and an operate
// composition are different species, and read/experience surfaces get their own.
export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']);
export function compositionContentHash(composition) {
+9 -9
View File
@@ -1,11 +1,12 @@
import crypto from 'node:crypto';
import { readFileSync } from 'node:fs';
import { WELL_TIERS } from './roll-selection.mjs';
export const CONCEPT_STATUSES = new Set(['approved', 'rejected']);
// What a concept is actually strong at. Worlds carry a durable visual
// identity (their palette/type half is the magnet); compositions carry a
// staging or interaction idea (their topology half is the magnet) that can be
// composition or interaction idea (their topology half is the magnet) that can be
// dressed in any committed identity; duals fuse both inseparably. Direction
// seeds draw world|dual, surface seeds draw composition|dual.
export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']);
@@ -15,7 +16,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']);
// atmosphere worlds need the largest translation step. Every seed roll draws
// one challenger from each tier so at least one directly-usable graphic
// system is always on the table.
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
// Defined in roll-selection.mjs, the dependency-free leaf both the seeder and
// the roll API import. It cannot depend on this file: this one reads the
// filesystem, and a Pages Function must not pull node:fs into its bundle.
// Imported and re-exported rather than re-exported alone: a bare
// `export { X } from` does not bind X in this module's own scope, and
// validateConceptCatalog needs it.
export { WELL_TIERS };
const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i;
export const SYSTEM_PREFIXES = [
@@ -320,10 +327,3 @@ export function approvedPoolRevision(concepts) {
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12);
}
export function deterministicRank(items, input, idFor = item => item.id) {
return [...items].sort((a, b) => {
const scoreA = crypto.createHash('sha256').update(`${input}:${idFor(a)}`).digest('hex');
const scoreB = crypto.createHash('sha256').update(`${input}:${idFor(b)}`).digest('hex');
return scoreB.localeCompare(scoreA) || idFor(a).localeCompare(idFor(b));
});
}
+10 -8
View File
@@ -97,7 +97,7 @@ function compositionTickets(pool) {
export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) {
const approved = concepts.filter(concept => concept.status === 'approved');
// Direction chooses a durable identity, so it draws worlds; surface designs
// one page inside a committed identity, so it draws stagings. Duals serve
// one page inside a committed identity, so it draws compositions. Duals serve
// both. A tier with no matching-strength approvals falls back to its full
// approved pool rather than starving the roll.
const wanted = scope === 'direction'
@@ -176,12 +176,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
}
/**
* Three identity-free staging inputs from an explicit approved pool.
* Three identity-free composition inputs from an explicit approved pool.
* Drive with runSyncSelection or runAsyncSelection.
*
* One input was too weak a counterweight to a model's habitual page skeleton:
* it became a single optional flourish beside six identity challengers rather
* than a real search over composition. Distinct staging families are preferred
* than a real search over composition. Distinct composition families are preferred
* so a roll tests materially different hierarchy, sequence, and interaction
* laws. Cross-mode fallback would make the input misleading, so an absent mode
* returns nothing rather than borrowing. Re-rolls exclude every earlier set
@@ -196,11 +196,11 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
*/
export function* selectApprovedStagings({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) {
// Stagings honour the same breadth gate as worlds: one too specific to serve
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = 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
// nothing if every approved staging is niche.
// nothing if every approved composition is niche.
let approved = compositions.filter(composition => composition.status === 'approved');
const broad = approved.filter(composition => composition.review?.breadth !== 'niche');
if (broad.length > 0) approved = broad;
@@ -218,15 +218,17 @@ export function* selectApprovedStagings({ scope, key, reroll = 0, mode = null, c
const base = available.length >= Math.min(count, approved.length) ? available : approved;
// Rating weights the draw as it does for worlds. It matters more here
// because the per-surface pools are small, so an unweighted shuffle repeats
// a weak staging far more often. Each ticket carries its index so the rank
// a weak composition far more often. Each ticket carries its index so the rank
// sees a distinct key per ticket: ranking bare duplicates would hash
// identically and the pick loop's id-dedupe would silently discard the
// second copy, making the weighting a no-op.
let tickets = compositionTickets(base);
// A pool of nothing but 1-star keeps still has to yield stagings.
// A pool of nothing but 1-star keeps still has to yield compositions.
if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 }));
const ranked = (yield* rank(
tickets,
// The salt keeps the word "staging" deliberately. It is hash input, so
// renaming it would re-deal every roll anyone has ever reproduced by key.
round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`,
entry => `${entry.composition.id}#${entry.ticket}`
)).map(entry => entry.composition);
+20 -20
View File
@@ -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, selectApprovedStaging, selectApprovedStagings } from '../skill/scripts/concept-seed.mjs';
import { 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');
@@ -98,34 +98,34 @@ describe('concept seed scopes', () => {
assert.doesNotMatch(degraded.stdout, /CHALLENGERS:/);
});
it('keeps staging challengers inside the requested surface mode', () => {
it('keeps composition challengers inside the requested surface mode', () => {
const pool = [
{ id: 'persuade-stage', surface: 'persuade', status: 'approved' },
{ id: 'experience-stage', surface: 'experience', status: 'approved' },
];
const experience = selectApprovedStaging({
const experience = selectApprovedComposition({
scope: 'direction',
key: 'mode-match',
mode: 'experience',
sourceCompositions: pool,
});
const read = selectApprovedStaging({
const read = selectApprovedComposition({
scope: 'direction',
key: 'mode-missing',
mode: 'read',
sourceCompositions: pool,
});
assert.equal(experience?.id, 'experience-stage');
assert.equal(read, null, 'a missing mode must not borrow an unrelated staging');
assert.equal(read, null, 'a missing mode must not borrow an unrelated composition');
const rendered = run('direction', ['--mode', 'experience']);
assert.equal(rendered.status, 0);
assert.match(rendered.stdout, /mode: experience/);
assert.match(rendered.stdout, /--scope direction --mode experience --from stable-test/);
assert.match(rendered.stdout, /FIRST-SURFACE STAGING/);
assert.match(rendered.stdout, /FIRST-SURFACE COMPOSITION/);
});
it('draws several staging inputs from distinct families when the approved pool allows it', () => {
it('draws several composition inputs from distinct families when the approved pool allows it', () => {
const pool = [
{ id: 'a', familyId: 'first', surface: 'persuade', status: 'approved' },
{ id: 'b', familyId: 'scroll', surface: 'persuade', status: 'approved' },
@@ -133,7 +133,7 @@ describe('concept seed scopes', () => {
{ id: 'd', familyId: 'first', surface: 'persuade', status: 'approved' },
{ id: 'e', familyId: 'other', surface: 'operate', status: 'approved' },
];
const picks = selectApprovedStagings({ scope: 'direction', key: 'several', mode: 'persuade', sourceCompositions: pool });
const picks = selectApprovedCompositions({ scope: 'direction', key: 'several', mode: 'persuade', sourceCompositions: pool });
assert.equal(picks.length, 3);
assert.equal(new Set(picks.map(pick => pick.familyId)).size, 3);
assert.equal(picks.every(pick => pick.surface === 'persuade'), true);
@@ -364,7 +364,7 @@ describe('concept seed scopes', () => {
assert.equal(picks.some(pick => pick.id === 'lone-niche'), true);
});
it('weights staging draws by rating without letting the ticket dedupe erase the weight', () => {
it('weights composition draws by rating without letting the ticket dedupe erase the weight', () => {
const pool = [
{ id: 'flagship-stage', surface: 'persuade', status: 'approved', review: { status: 'approved', rating: 3 } },
{ id: 'plain-stage', surface: 'persuade', status: 'approved', review: { status: 'approved' } },
@@ -372,43 +372,43 @@ describe('concept seed scopes', () => {
];
const counts = { 'flagship-stage': 0, 'plain-stage': 0, 'marginal-stage': 0 };
for (let index = 0; index < 300; index += 1) {
const picks = selectApprovedStagings({ scope: 'direction', key: `stage-weight-${index}`, mode: 'persuade', sourceCompositions: pool, count: 1 });
const picks = selectApprovedCompositions({ scope: 'direction', key: `stage-weight-${index}`, mode: 'persuade', sourceCompositions: pool, count: 1 });
counts[picks[0].id] += 1;
}
assert.equal(counts['marginal-stage'], 0, 'a 1-star staging keeps its approval but leaves the draw');
assert.equal(counts['marginal-stage'], 0, 'a 1-star composition keeps its approval but leaves the draw');
// Two tickets should put the flagship first roughly twice as often as the
// unrated peer; a generous margin keeps the assertion deterministic-safe.
assert.equal(counts['flagship-stage'] > counts['plain-stage'] * 1.3, true,
`flagship ${counts['flagship-stage']} vs plain ${counts['plain-stage']}`);
// A pool of nothing but 1-star keeps still yields stagings.
// A pool of nothing but 1-star keeps still yields compositions.
const onlyMarginal = [
{ id: 'lone-marginal-stage', surface: 'persuade', status: 'approved', review: { status: 'approved', rating: 1 } },
];
const fallback = selectApprovedStagings({ scope: 'direction', key: 'stage-lone', mode: 'persuade', sourceCompositions: onlyMarginal });
const fallback = selectApprovedCompositions({ scope: 'direction', key: 'stage-lone', mode: 'persuade', sourceCompositions: onlyMarginal });
assert.equal(fallback.some(pick => pick.id === 'lone-marginal-stage'), true);
});
it('gates stagings by breadth and falls back when every staging is niche', () => {
it('gates compositions by breadth and falls back when every composition is niche', () => {
const pool = [
{ id: 'broad-stage', surface: 'persuade', status: 'approved' },
{ id: 'niche-stage', surface: 'persuade', status: 'approved', review: { breadth: 'niche' } },
];
for (let index = 0; index < 60; index += 1) {
const picks = selectApprovedStagings({ scope: 'direction', key: `stage-breadth-${index}`, mode: 'persuade', sourceCompositions: pool });
assert.equal(picks.some(pick => pick.id === 'niche-stage'), false, `niche staging dealt at key stage-breadth-${index}`);
const picks = selectApprovedCompositions({ scope: 'direction', key: `stage-breadth-${index}`, mode: 'persuade', sourceCompositions: pool });
assert.equal(picks.some(pick => pick.id === 'niche-stage'), false, `niche composition dealt at key stage-breadth-${index}`);
}
const allNiche = [
{ id: 'only-niche-stage', surface: 'persuade', status: 'approved', review: { breadth: 'niche' } },
];
const fallback = selectApprovedStagings({ scope: 'direction', key: 'all-niche', mode: 'persuade', sourceCompositions: allNiche });
const fallback = selectApprovedCompositions({ scope: 'direction', key: 'all-niche', mode: 'persuade', sourceCompositions: allNiche });
assert.equal(fallback.some(pick => pick.id === 'only-niche-stage'), true, 'an all-niche pool must fall back instead of dealing nothing');
});
it('mode-filters the fixture staging pool per surface register', () => {
const operate = selectApprovedStaging({ scope: 'surface', key: 'fix-mode', mode: 'operate', sourceCompositions: fixtureCompositions });
it('mode-filters the fixture composition pool per surface register', () => {
const operate = selectApprovedComposition({ scope: 'surface', key: 'fix-mode', mode: 'operate', sourceCompositions: fixtureCompositions });
assert.equal(operate.surface, 'operate');
const experience = selectApprovedStaging({ scope: 'surface', key: 'fix-mode', mode: 'experience', sourceCompositions: fixtureCompositions });
const experience = selectApprovedComposition({ scope: 'surface', key: 'fix-mode', mode: 'experience', sourceCompositions: fixtureCompositions });
assert.equal(experience.surface, 'experience');
});