diff --git a/skill/scripts/lib/roll-selection.mjs b/skill/scripts/lib/roll-selection.mjs index e3c9efbb8..6fab19396 100644 --- a/skill/scripts/lib/roll-selection.mjs +++ b/skill/scripts/lib/roll-selection.mjs @@ -96,31 +96,38 @@ function* rank(items, input, idFor = item => item.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. +// Rating sets how many tickets a world holds; breadth decides whether it draws +// at all. A niche world leaves the pool 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. +// +// Two tickets for a 3-star, one for everything else, was too sharp. Measured +// against the catalog as it stood: 3-star worlds absorbed 57% of the graphic +// draw from 65 of 163 eligible worlds, 46% of atmosphere from 13 of 43, and +// 75% of interaction from 15 of 25. The reviewer's complaint, that the same +// worlds keep coming back, is what a rating multiplier does to a pool whose +// thinnest tier holds 25 worlds. +// +// So a 3-star no longer outdraws a 2-star, and a 1-star draws at half rather +// than not at all. A marginal keep is still worth showing sometimes: the +// judgement it records is "narrow or unexceptional", not "wrong", and excluding +// it entirely made a rating do a job breadth already does properly. +const RATING_TICKETS = { 1: 1, 2: 2, 3: 2 }; +const ticketsForRating = rating => RATING_TICKETS[rating] ?? 2; + 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 }]; + if (concept.review?.breadth === 'niche') return []; + return Array.from({ length: ticketsForRating(concept.review?.rating) }, + (_, ticket) => ({ concept, ticket })); }); } 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 }]; - }); + return pool.flatMap(composition => Array.from( + { length: ticketsForRating(composition.review?.rating) }, + (_, ticket) => ({ composition, ticket }))); } /** diff --git a/tests/concept-seed.test.mjs b/tests/concept-seed.test.mjs index 125550ce8..2717dd849 100644 --- a/tests/concept-seed.test.mjs +++ b/tests/concept-seed.test.mjs @@ -314,11 +314,19 @@ describe('concept seed scopes', () => { const graphicFirst = picks.find(pick => pick.wellTier === 'graphic'); counts[graphicFirst.id] += 1; } - assert.equal(counts.marginal, 0); - // Two tickets should put the flagship on top roughly twice as often as an - // unrated peer; a generous margin keeps the assertion deterministic-safe. - assert.equal(counts.flagship > counts['solid-b'] * 1.3, true, - `flagship ${counts.flagship} vs solid-b ${counts['solid-b']}`); + // A 1-star draws at half weight rather than not at all. Excluding it made a + // rating do the job breadth already does, and a marginal keep records + // "narrow or unexceptional" rather than "wrong". + assert.equal(counts.marginal > 0, true, `marginal ${counts.marginal} should draw`); + assert.equal(counts.marginal < counts['solid-b'], true, + `marginal ${counts.marginal} should draw below solid-b ${counts['solid-b']}`); + + // A 3-star no longer outdraws a 2-star. The multiplier concentrated the + // draw hard on a thin pool: measured on the live catalog, 3-star worlds took + // 75% of the interaction draw from 15 of 25 eligible worlds. + const spread = Math.abs(counts.flagship - counts['solid-b']) / Math.max(counts.flagship, counts['solid-b']); + assert.equal(spread < 0.4, true, + `flagship ${counts.flagship} and solid-b ${counts['solid-b']} should draw comparably`); // A tier holding only 1-star approvals still yields challengers. const onlyMarginal = [ @@ -380,11 +388,15 @@ describe('concept seed scopes', () => { const picks = selectApprovedCompositions({ scope: 'direction', key: `stage-weight-${index}`, mode: 'persuade', sourceCompositions: pool, count: 1 }); counts[picks[0].id] += 1; } - assert.equal(counts['marginal-stage'], 0, 'a 1-star composition keeps its approval but leaves the draw'); - // Two tickets should put the flagship first roughly twice as often as the - // unrated peer; a generous margin keeps the assertion deterministic-safe. - assert.equal(counts['flagship-stage'] > counts['plain-stage'] * 1.3, true, - `flagship ${counts['flagship-stage']} vs plain ${counts['plain-stage']}`); + // Same weighting as challengers: a 1-star draws at half rather than not at + // all, and a 3-star no longer outdraws a 2-star. + assert.equal(counts['marginal-stage'] > 0, true, 'a 1-star composition still draws, at half weight'); + assert.equal(counts['marginal-stage'] < counts['plain-stage'], true, + `marginal ${counts['marginal-stage']} should draw below plain ${counts['plain-stage']}`); + const stageSpread = Math.abs(counts['flagship-stage'] - counts['plain-stage']) + / Math.max(counts['flagship-stage'], counts['plain-stage']); + assert.equal(stageSpread < 0.4, true, + `flagship ${counts['flagship-stage']} and plain ${counts['plain-stage']} should draw comparably`); // A pool of nothing but 1-star keeps still yields compositions. const onlyMarginal = [