mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
308 lines
14 KiB
JavaScript
308 lines
14 KiB
JavaScript
// The one implementation of world-roll selection.
|
|
//
|
|
// Two copies of this logic used to exist: this repo's concept-seed.mjs and the
|
|
// service repo's functions/api/_worldroll-core.js, whose header claimed they
|
|
// matched "exactly". They did not. The API had no breadth gate on either pool,
|
|
// no rating weighting for compositions, and dealt one composition where the
|
|
// seeder dealt three. Because the catalog never ships with the skill, every real
|
|
// user rolls through that API, so those gates reached nobody.
|
|
//
|
|
// Why generators. The two callers cannot agree on a hash: Node has a
|
|
// synchronous one, Workers only have async crypto.subtle, and concept-seed's
|
|
// local render path is deliberately synchronous so prepared eval sessions and
|
|
// tests can call it without awaiting. Rather than fork the logic or force the
|
|
// whole seeder async, the selection is written once as a generator that yields
|
|
// batches of strings to hash and resumes with their digests. runSyncSelection
|
|
// and runAsyncSelection below are the only runtime-specific code, about eight
|
|
// lines each. Both digests are the same bytes, so a roll is identical either way.
|
|
//
|
|
// Nothing here reads a file, an environment variable, or the network: callers
|
|
// pass pools in.
|
|
|
|
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.
|
|
//
|
|
// `area` is optional. An entry without one is eligible for any request in its
|
|
// surface, so nothing has to be backfilled before this ships, and a request for
|
|
// an area with a thin pool tops up from the rest of the surface rather than
|
|
// dealing fewer.
|
|
export const COMPOSITION_AREAS = {
|
|
persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'],
|
|
operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'],
|
|
read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'],
|
|
experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'],
|
|
};
|
|
|
|
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
|
|
|
|
export function areasForSurface(surface) {
|
|
return COMPOSITION_AREAS[surface] ?? [];
|
|
}
|
|
|
|
|
|
/**
|
|
* Drives a selection generator with a synchronous hash.
|
|
* @param {Generator} generator yields string[] to hash, resumes with hex string[]
|
|
* @param {(input: string) => string} hash
|
|
*/
|
|
export function runSyncSelection(generator, hash) {
|
|
let step = generator.next();
|
|
while (!step.done) step = generator.next(step.value.map(hash));
|
|
return step.value;
|
|
}
|
|
|
|
/**
|
|
* Drives a selection generator with an asynchronous hash.
|
|
* @param {Generator} generator
|
|
* @param {(input: string) => Promise<string>} hash
|
|
*/
|
|
export async function runAsyncSelection(generator, hash) {
|
|
let step = generator.next();
|
|
while (!step.done) step = generator.next(await Promise.all(step.value.map(hash)));
|
|
return step.value;
|
|
}
|
|
|
|
// Ranks items by the digest of `${input}:${id}`, descending, with the id as a
|
|
// stable tiebreak. Yields every needed digest in one batch so the async driver
|
|
// can resolve them concurrently.
|
|
function* rank(items, input, idFor = item => item.id) {
|
|
const ids = items.map(idFor);
|
|
const digests = yield ids.map(id => `${input}:${id}`);
|
|
return items
|
|
.map((item, index) => ({ item, id: ids[index], score: digests[index] }))
|
|
.sort((a, b) => b.score.localeCompare(a.score) || a.id.localeCompare(b.id))
|
|
.map(entry => entry.item);
|
|
}
|
|
|
|
// Two independent exclusions, and either one is enough to hold a world back.
|
|
// Rating grades quality: a 3-star earns a second ticket, a 1-star marginal keep
|
|
// leaves the pool. Breadth says whether a world can serve an arbitrary build at
|
|
// all, so a niche world leaves however good it is, keeping its approval for
|
|
// direct briefs. Breadth was split out of rating because the only way to hold a
|
|
// narrow world back used to be calling it marginal, which made "excellent but
|
|
// narrow" unrecordable and corrupted ratings as a calibration signal.
|
|
function challengerTickets(pool) {
|
|
return pool.flatMap(concept => {
|
|
const rating = concept.review?.rating;
|
|
if (rating === 1 || concept.review?.breadth === 'niche') return [];
|
|
return rating === 3
|
|
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
|
|
: [{ concept, ticket: 0 }];
|
|
});
|
|
}
|
|
|
|
function compositionTickets(pool) {
|
|
return pool.flatMap(composition => {
|
|
const rating = composition.review?.rating;
|
|
if (rating === 1) return [];
|
|
return rating === 3
|
|
? [{ composition, ticket: 0 }, { composition, ticket: 1 }]
|
|
: [{ composition, ticket: 0 }];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Six challengers, two per translation tier, from an explicit approved pool.
|
|
* Drive with runSyncSelection or runAsyncSelection.
|
|
*
|
|
* @param {object} options
|
|
* @param {'direction'|'surface'} options.scope
|
|
* @param {string} options.key same key reproduces the roll
|
|
* @param {number} [options.reroll] round of the re-roll chain
|
|
* @param {number|null} [options.minRating] optional floor, skipped per tier it would empty
|
|
* @param {Array} options.concepts merged concepts with status, review, wellTier, familyId
|
|
* @returns {Generator<string[], {approved: Array, picks: Array}, string[]>}
|
|
*/
|
|
// A world with no allowedModes is eligible everywhere, which is what keeps this
|
|
// additive: nothing has to be backfilled for the filter to be safe.
|
|
function modeAllows(concept, mode) {
|
|
const allowed = concept.review?.allowedModes;
|
|
if (!Array.isArray(allowed) || allowed.length === 0) return true;
|
|
return allowed.includes(mode);
|
|
}
|
|
|
|
export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) {
|
|
const approved = concepts.filter(concept => concept.status === 'approved');
|
|
// Direction chooses a durable identity, so it draws worlds; surface designs
|
|
// one page inside a committed identity, so it draws compositions. Duals serve
|
|
// both. A tier with no matching-strength approvals falls back to its full
|
|
// approved pool rather than starving the roll.
|
|
const wanted = scope === 'direction'
|
|
? new Set(['world', 'dual'])
|
|
: new Set(['composition', 'dual']);
|
|
|
|
const approvedByTier = new Map();
|
|
for (const concept of approved) {
|
|
const tier = approvedByTier.get(concept.wellTier) || [];
|
|
tier.push(concept);
|
|
approvedByTier.set(concept.wellTier, tier);
|
|
}
|
|
if (WELL_TIERS.some(tier => !(approvedByTier.get(tier) || []).length)) {
|
|
throw new Error('concept-seed: every challenger tier needs at least one approved concept');
|
|
}
|
|
|
|
// Optional minimum-rating gate, applied per tier and skipped for any tier it
|
|
// would empty, so a thin tier degrades to its full approved pool.
|
|
if (minRating) {
|
|
for (const [tier, pool] of approvedByTier) {
|
|
const rated = pool.filter(concept => (concept.review?.rating || 0) >= minRating);
|
|
if (rated.length > 0) approvedByTier.set(tier, rated);
|
|
}
|
|
}
|
|
// Mode eligibility, per tier and skipped where it would empty a tier. Worlds
|
|
// used to be drawn with no mode awareness at all, so a build asking for an app
|
|
// UI could get six worlds that only make sense on a landing page. A world is an
|
|
// identity and identities transfer further than compositions do, so this is a
|
|
// ceiling the reviewer sets rather than a category assignment: eligible
|
|
// everywhere until someone says otherwise.
|
|
if (mode) {
|
|
for (const [tier, pool] of approvedByTier) {
|
|
const eligible = pool.filter(concept => modeAllows(concept, mode));
|
|
if (eligible.length > 0) approvedByTier.set(tier, eligible);
|
|
}
|
|
}
|
|
for (const [tier, pool] of approvedByTier) {
|
|
const matching = pool.filter(concept => wanted.has(concept.strength));
|
|
if (matching.length > 0) approvedByTier.set(tier, matching);
|
|
}
|
|
|
|
// Two challengers per tier, so every roll carries near-zero-translation
|
|
// graphic systems beside instrument languages and atmosphere worlds, with the
|
|
// second pick preferring a different family. Tier order is rolled too, to
|
|
// avoid positional bias.
|
|
function* pickRound(round, excluded) {
|
|
const salt = round === 0 ? '' : `:reroll-${round}`;
|
|
const tierOrder = (yield* rank(
|
|
WELL_TIERS.map(id => ({ id })),
|
|
`${scope}:${key}:tiers${salt}`
|
|
)).map(item => item.id);
|
|
const picks = [];
|
|
for (const [index, tier] of tierOrder.entries()) {
|
|
let pool = approvedByTier.get(tier).filter(concept => !excluded.has(concept.id));
|
|
// A tier exhausted by prior rounds falls back to reuse over starvation.
|
|
if (pool.length === 0) pool = approvedByTier.get(tier);
|
|
let tickets = challengerTickets(pool);
|
|
if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 }));
|
|
const ranked = yield* rank(
|
|
tickets,
|
|
`${scope}:${key}:challenger-${index}${salt}`,
|
|
entry => `${entry.concept.id}#${entry.ticket}`
|
|
);
|
|
const order = [];
|
|
const seen = new Set();
|
|
for (const entry of ranked) {
|
|
if (seen.has(entry.concept.id)) continue;
|
|
seen.add(entry.concept.id);
|
|
order.push(entry.concept);
|
|
}
|
|
const first = order[0];
|
|
const second = order.find(concept => concept.familyId !== first.familyId)
|
|
|| order.find(concept => concept.id !== first.id);
|
|
picks.push(...(second ? [first, second] : [first]));
|
|
}
|
|
return picks;
|
|
}
|
|
|
|
// Round n of a re-roll chain excludes everything rounds 0..n-1 drew, so the
|
|
// same base key reproduces the whole chain.
|
|
const excluded = new Set();
|
|
let picks = yield* pickRound(0, excluded);
|
|
for (let round = 1; round <= reroll; round += 1) {
|
|
for (const pick of picks) excluded.add(pick.id);
|
|
picks = yield* pickRound(round, excluded);
|
|
}
|
|
return { approved, picks };
|
|
}
|
|
|
|
/**
|
|
* 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 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
|
|
* until the pool runs out.
|
|
*
|
|
* @param {object} options
|
|
* @param {'direction'|'surface'} options.scope
|
|
* @param {string} options.key
|
|
* @param {number} [options.reroll]
|
|
* @param {string|null} [options.mode] surface register to stay inside
|
|
* @param {string|null} [options.area] area of concern to prefer within that surface
|
|
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
|
|
* @param {number} [options.count]
|
|
* @returns {Generator<string[], Array, string[]>}
|
|
*/
|
|
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
|
|
// Compositions honour the same breadth gate as worlds: one too specific to serve
|
|
// an arbitrary build stays approved for direct briefs and leaves the
|
|
// challenger pool. Falls back to the full approved set rather than returning
|
|
// 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;
|
|
if (approved.length === 0) return [];
|
|
if (mode) {
|
|
const matching = approved.filter(composition => composition.surface === mode);
|
|
if (matching.length === 0) return [];
|
|
approved = matching;
|
|
}
|
|
|
|
const prior = new Set();
|
|
let picks = [];
|
|
for (let round = 0; round <= reroll; round += 1) {
|
|
const available = approved.filter(composition => !prior.has(composition.id));
|
|
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 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 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);
|
|
|
|
// Area is a preference, not a filter. Requesting an onboarding flow should
|
|
// deal onboarding compositions first and top up from the rest of the surface
|
|
// rather than deal fewer than three, because the per-area pools are small and
|
|
// an unset area is eligible everywhere. A stable partition of an already
|
|
// deterministic ranking is still deterministic.
|
|
const ordered = area
|
|
? [...ranked.filter(composition => composition.area === area),
|
|
...ranked.filter(composition => composition.area !== area)]
|
|
: ranked;
|
|
|
|
const families = new Set();
|
|
picks = [];
|
|
for (const composition of ordered) {
|
|
const family = composition.familyId ?? composition.id;
|
|
if (families.has(family)) continue;
|
|
picks.push(composition);
|
|
families.add(family);
|
|
if (picks.length >= count) break;
|
|
}
|
|
for (const composition of ordered) {
|
|
if (picks.length >= count) break;
|
|
if (!picks.some(pick => pick.id === composition.id)) picks.push(composition);
|
|
}
|
|
if (round < reroll) picks.forEach(composition => prior.add(composition.id));
|
|
}
|
|
return picks;
|
|
}
|