From 71d7b5e3105fb0bfc86b093fd488e0f684ae8811 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:06:03 +0000 Subject: [PATCH] Sync generated provider output --- .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ .../impeccable/scripts/concept-seed.mjs | 163 ++---------- .../impeccable/scripts/lib/roll-selection.mjs | 250 ++++++++++++++++++ 30 files changed, 4005 insertions(+), 2190 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .claude/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .cursor/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .gemini/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .github/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .grok/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .kiro/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .opencode/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .pi/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .qoder/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .rovodev/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .trae/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .vibe/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 plugin/skills/impeccable/scripts/lib/roll-selection.mjs diff --git a/.agents/skills/impeccable/scripts/concept-seed.mjs b/.agents/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.agents/skills/impeccable/scripts/concept-seed.mjs +++ b/.agents/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.agents/skills/impeccable/scripts/lib/roll-selection.mjs b/.agents/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.agents/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.claude/skills/impeccable/scripts/concept-seed.mjs b/.claude/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.claude/skills/impeccable/scripts/concept-seed.mjs +++ b/.claude/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.claude/skills/impeccable/scripts/lib/roll-selection.mjs b/.claude/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.claude/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.cursor/skills/impeccable/scripts/concept-seed.mjs b/.cursor/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.cursor/skills/impeccable/scripts/concept-seed.mjs +++ b/.cursor/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs b/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.gemini/skills/impeccable/scripts/concept-seed.mjs b/.gemini/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.gemini/skills/impeccable/scripts/concept-seed.mjs +++ b/.gemini/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs b/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.github/skills/impeccable/scripts/concept-seed.mjs b/.github/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.github/skills/impeccable/scripts/concept-seed.mjs +++ b/.github/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.github/skills/impeccable/scripts/lib/roll-selection.mjs b/.github/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.github/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.grok/skills/impeccable/scripts/concept-seed.mjs b/.grok/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.grok/skills/impeccable/scripts/concept-seed.mjs +++ b/.grok/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.grok/skills/impeccable/scripts/lib/roll-selection.mjs b/.grok/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.grok/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.kiro/skills/impeccable/scripts/concept-seed.mjs b/.kiro/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.kiro/skills/impeccable/scripts/concept-seed.mjs +++ b/.kiro/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs b/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.opencode/skills/impeccable/scripts/concept-seed.mjs b/.opencode/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.opencode/skills/impeccable/scripts/concept-seed.mjs +++ b/.opencode/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs b/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.pi/skills/impeccable/scripts/concept-seed.mjs b/.pi/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.pi/skills/impeccable/scripts/concept-seed.mjs +++ b/.pi/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.pi/skills/impeccable/scripts/lib/roll-selection.mjs b/.pi/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.pi/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.qoder/skills/impeccable/scripts/concept-seed.mjs b/.qoder/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.qoder/skills/impeccable/scripts/concept-seed.mjs +++ b/.qoder/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs b/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.rovodev/skills/impeccable/scripts/concept-seed.mjs b/.rovodev/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.rovodev/skills/impeccable/scripts/concept-seed.mjs +++ b/.rovodev/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs b/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.trae-cn/skills/impeccable/scripts/concept-seed.mjs b/.trae-cn/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.trae-cn/skills/impeccable/scripts/concept-seed.mjs +++ b/.trae-cn/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs b/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.trae/skills/impeccable/scripts/concept-seed.mjs b/.trae/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.trae/skills/impeccable/scripts/concept-seed.mjs +++ b/.trae/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.trae/skills/impeccable/scripts/lib/roll-selection.mjs b/.trae/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.trae/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/.vibe/skills/impeccable/scripts/concept-seed.mjs b/.vibe/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/.vibe/skills/impeccable/scripts/concept-seed.mjs +++ b/.vibe/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs b/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +} diff --git a/plugin/skills/impeccable/scripts/concept-seed.mjs b/plugin/skills/impeccable/scripts/concept-seed.mjs index abf9e2aba..00a2f73fe 100644 --- a/plugin/skills/impeccable/scripts/concept-seed.mjs +++ b/plugin/skills/impeccable/scripts/concept-seed.mjs @@ -64,12 +64,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, - deterministicRank, readConceptCatalog, validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { + runSyncSelection, + selectApprovedChallengers as selectApprovedChallengersCore, + selectApprovedStagings as selectApprovedStagingsCore, +} from './lib/roll-selection.mjs'; const here = dirname(fileURLToPath(import.meta.url)); @@ -197,72 +201,18 @@ ${grammar} WEB LEVERAGE: ${composition.webLeverage}`; } -// Three approved, identity-free staging inputs are rolled deterministically. -// 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. Prefer distinct staging families so a -// roll tests materially different hierarchy, sequence, and interaction laws. -// Cross-mode fallback would make the input misleading, so an absent mode still -// returns no staging. Re-rolls exclude every earlier set until the pool runs out. -export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { - const pool = sourceCompositions ?? requireLocalConcepts().compositions; - // Stagings honour the same breadth gate as worlds: a staging 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 marked niche. - let approved = pool.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; - } - // Rating weights the draw exactly as it does for world challengers: a 3-star - // staging earns a second ticket, a 1-star marginal keep leaves the pool. This - // matters more here than for worlds because the per-surface pools are small, - // so an unweighted shuffle repeats a weak staging far more often. - // Each ticket carries its index so deterministicRank 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 weighting a no-op. - const ticketsFor = pool => pool.flatMap(composition => { - const rating = composition.review?.rating; - if (rating === 1) return []; - return rating === 3 - ? [{ composition, ticket: 0 }, { composition, ticket: 1 }] - : [{ composition, ticket: 0 }]; - }); +// Selection itself lives in lib/roll-selection.mjs so this script and the roll +// API run one algorithm rather than two that drifted. These wrappers add only +// what is local to the skill: resolving the catalog when no pool is passed, and +// driving the generator with Node's synchronous hash, which keeps a local render +// synchronous for prepared eval sessions and tests. +function driveSelection(generator) { + return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); +} - 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; - let tickets = ticketsFor(base); - // A pool of nothing but 1-star keeps still has to yield stagings. - if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); - const ranked = deterministicRank( - tickets, - round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, - entry => `${entry.composition.id}#${entry.ticket}` - ).map(entry => entry.composition); - const families = new Set(); - picks = []; - for (const composition of ranked) { - const family = composition.familyId ?? composition.id; - if (families.has(family)) continue; - picks.push(composition); - families.add(family); - if (picks.length >= count) break; - } - for (const composition of ranked) { - 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; +export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { + const compositions = sourceCompositions ?? requireLocalConcepts().compositions; + return driveSelection(selectApprovedStagingsCore({ scope, key, reroll, mode, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -272,86 +222,7 @@ export function selectApprovedStaging(options) { export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const approved = source.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 - // 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'); - } - 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 for diversity. Tier order - // in the rendered list is rolled too, to avoid positional bias. - // Two separate axes, and both can exclude. 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 the pool however good it is. 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 the ratings as - // a calibration signal for the next authoring round. - const ticketsFor = pool => pool.flatMap(concept => { - const rating = concept.review?.rating; - // Two independent exclusions: a marginal world is too weak to challenge, - // a niche world too narrow. Either one keeps its approval for direct - // briefs and leaves the pool. - if (rating === 1 || concept.review?.breadth === 'niche') return []; - return rating === 3 - ? [{ concept, ticket: 0 }, { concept, ticket: 1 }] - : [{ concept, ticket: 0 }]; - }); - const pickRound = (round, excluded) => { - const salt = round === 0 ? '' : `:reroll-${round}`; - const tierOrder = deterministicRank( - WELL_TIERS.map(id => ({ id })), - `${scope}:${key}:tiers${salt}` - ).map(item => item.id); - return tierOrder.flatMap((tier, index) => { - 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 = ticketsFor(pool); - if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 })); - const ranked = deterministicRank( - 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); - return second ? [first, second] : [first]; - }); - }; - // 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 = pickRound(0, excluded); - for (let round = 1; round <= reroll; round += 1) { - for (const pick of picks) excluded.add(pick.id); - picks = pickRound(round, excluded); - } + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); return { approved, picks, diff --git a/plugin/skills/impeccable/scripts/lib/roll-selection.mjs b/plugin/skills/impeccable/scripts/lib/roll-selection.mjs new file mode 100644 index 000000000..c6829dd9e --- /dev/null +++ b/plugin/skills/impeccable/scripts/lib/roll-selection.mjs @@ -0,0 +1,250 @@ +// 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']; + +/** + * 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} 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} + */ +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 + // 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); + } + } + 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 staging 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 + * 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 {Array} options.compositions merged compositions with status, review, surface, familyId + * @param {number} [options.count] + * @returns {Generator} + */ +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 + // 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. + 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 staging 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. + if (tickets.length === 0) tickets = base.map(composition => ({ composition, ticket: 0 })); + const ranked = (yield* rank( + tickets, + round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`, + entry => `${entry.composition.id}#${entry.ticket}` + )).map(entry => entry.composition); + + const families = new Set(); + picks = []; + for (const composition of ranked) { + const family = composition.familyId ?? composition.id; + if (families.has(family)) continue; + picks.push(composition); + families.add(family); + if (picks.length >= count) break; + } + for (const composition of ranked) { + 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; +}