Weight staging draws by rating, with catalog validation for composition grades

Stagings now honour approval ratings exactly as world challengers do, a
3-star earning a second ticket and a 1-star marginal keep leaving the
pool, which matters more here because per-surface staging pools are
small enough that an unweighted shuffle repeats a weak staging often.
Each ticket carries its index into the deterministic ranking so the
id-dedupe cannot silently collapse the doubled odds into a no-op, and
an all-marginal pool still deals rather than starving. The composition
catalog validates the new grades: 1-3, approved entries only. Tests
cover the weighting, the dedupe subtlety, and the fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-28 16:26:38 -07:00
co-authored by Claude Fable 5
parent cb8144dd12
commit a68b74e787
3 changed files with 57 additions and 3 deletions
+23 -3
View File
@@ -219,14 +219,34 @@ export function selectApprovedStagings({ scope, key, reroll = 0, mode = null, so
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 }];
});
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(
available.length >= Math.min(count, approved.length) ? available : approved,
round === 0 ? `${scope}:${key}:staging` : `${scope}:${key}:staging:reroll-${round}`
);
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) {
@@ -149,6 +149,15 @@ export function validateCompositionCatalog(catalog, reviewData, { minimumTotal }
errors.push(`composition review ${id} is stale: content changed since review`);
}
}
// Mirrors the concept catalog: an optional 1-3 grade on approved entries
// only, read as a calibration signal and used to weight challenger draws.
if (review?.rating !== undefined) {
if (![1, 2, 3].includes(review.rating)) {
errors.push(`review ${id} rating must be 1, 2, or 3`);
} else if (review.status !== 'approved') {
errors.push(`review ${id} rating only applies to approved compositions`);
}
}
if (review?.note !== undefined && (typeof review.note !== 'string' || !review.note.trim() || review.note.length > 500)) {
errors.push(`composition review ${id} note must be a non-empty string of 500 characters or fewer`);
}
+25
View File
@@ -364,6 +364,31 @@ describe('concept seed scopes', () => {
assert.equal(picks.some(pick => pick.id === 'lone-niche'), true);
});
it('weights staging draws by rating without letting the ticket dedupe erase the weight', () => {
const pool = [
{ id: 'flagship-stage', surface: 'persuade', status: 'approved', review: { status: 'approved', rating: 3 } },
{ id: 'plain-stage', surface: 'persuade', status: 'approved', review: { status: 'approved' } },
{ id: 'marginal-stage', surface: 'persuade', status: 'approved', review: { status: 'approved', rating: 1 } },
];
const counts = { 'flagship-stage': 0, 'plain-stage': 0, 'marginal-stage': 0 };
for (let index = 0; index < 300; index += 1) {
const picks = selectApprovedStagings({ scope: 'direction', key: `stage-weight-${index}`, mode: 'persuade', sourceCompositions: pool, count: 1 });
counts[picks[0].id] += 1;
}
assert.equal(counts['marginal-stage'], 0, 'a 1-star staging keeps its approval but leaves the draw');
// Two tickets should put the flagship first roughly twice as often as the
// unrated peer; a generous margin keeps the assertion deterministic-safe.
assert.equal(counts['flagship-stage'] > counts['plain-stage'] * 1.3, true,
`flagship ${counts['flagship-stage']} vs plain ${counts['plain-stage']}`);
// A pool of nothing but 1-star keeps still yields stagings.
const onlyMarginal = [
{ id: 'lone-marginal-stage', surface: 'persuade', status: 'approved', review: { status: 'approved', rating: 1 } },
];
const fallback = selectApprovedStagings({ scope: 'direction', key: 'stage-lone', mode: 'persuade', sourceCompositions: onlyMarginal });
assert.equal(fallback.some(pick => pick.id === 'lone-marginal-stage'), true);
});
it('gates stagings by breadth and falls back when every staging is niche', () => {
const pool = [
{ id: 'broad-stage', surface: 'persuade', status: 'approved' },