Add the mode and area axes to the roll

Two gaps, both reported from real use. Worlds were drawn with no mode
awareness at all: selectApprovedChallengers never received the mode, so a
build asking for an app UI could draw six worlds that only make sense on a
landing page. And surface alone is too coarse for compositions, because
"operate" spans onboarding, dashboards, editors and settings, so an
onboarding flow could legitimately be dealt a settings composition.

Worlds gain `allowedModes` on the review record, beside breadth and rating,
because it is a reviewer judgment rather than authored content. Absent means
eligible in every mode, so nothing needs backfilling and no existing roll
changes. Applied per tier and skipped where it would empty one, matching how
minRating and strength already degrade. It is a ceiling the reviewer lowers,
not a category they assign: a world is an identity, and identities transfer
across modes further than compositions do.

Compositions gain an optional `area`, one level below surface, with a
taxonomy per surface (COMPOSITION_AREAS). Area is a preference rather than a
filter: a request reorders the ranking to put area matches first and tops up
from the rest of the surface, because the per-area pools are small and
dealing one on-target composition would be worse than three good ones. A
stable partition of an already deterministic ranking stays deterministic.

`--area` on the CLI requires `--mode`, since areas are scoped to a surface,
and is validated against that surface's list so a wrong-surface area fails
loudly instead of silently matching nothing.

Also validated `breadth`, which selection has honoured for a while with
nothing checking it, so a typo read as "general" and quietly returned a
narrow world to the pool.

Four new tests: worlds excluded from a mode stay out, absent allowedModes
stays eligible everywhere, a tier whose every world excludes the mode falls
back instead of starving, and an area-scoped deal prefers its area, tops up
to three, and reproduces from its key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-29 16:45:20 -07:00
co-authored by Claude Opus 5
parent 7c32d42055
commit a94331baf0
5 changed files with 246 additions and 12 deletions
+35 -4
View File
@@ -94,7 +94,15 @@ function compositionTickets(pool) {
* @param {Array} options.concepts merged concepts with status, review, wellTier, familyId
* @returns {Generator<string[], {approved: Array, picks: Array}, string[]>}
*/
export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) {
// A world with no allowedModes is eligible everywhere, which is what keeps this
// additive: nothing has to be backfilled for the filter to be safe.
function modeAllows(concept, mode) {
const allowed = concept.review?.allowedModes;
if (!Array.isArray(allowed) || allowed.length === 0) return true;
return allowed.includes(mode);
}
export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) {
const approved = concepts.filter(concept => concept.status === 'approved');
// Direction chooses a durable identity, so it draws worlds; surface designs
// one page inside a committed identity, so it draws compositions. Duals serve
@@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
if (rated.length > 0) approvedByTier.set(tier, rated);
}
}
// Mode eligibility, per tier and skipped where it would empty a tier. Worlds
// used to be drawn with no mode awareness at all, so a build asking for an app
// UI could get six worlds that only make sense on a landing page. A world is an
// identity and identities transfer further than compositions do, so this is a
// ceiling the reviewer sets rather than a category assignment: eligible
// everywhere until someone says otherwise.
if (mode) {
for (const [tier, pool] of approvedByTier) {
const eligible = pool.filter(concept => modeAllows(concept, mode));
if (eligible.length > 0) approvedByTier.set(tier, eligible);
}
}
for (const [tier, pool] of approvedByTier) {
const matching = pool.filter(concept => wanted.has(concept.strength));
if (matching.length > 0) approvedByTier.set(tier, matching);
@@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating =
* @param {string} options.key
* @param {number} [options.reroll]
* @param {string|null} [options.mode] surface register to stay inside
* @param {string|null} [options.area] area of concern to prefer within that surface
* @param {Array} options.compositions merged compositions with status, review, surface, familyId
* @param {number} [options.count]
* @returns {Generator<string[], Array, string[]>}
*/
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) {
export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) {
// Compositions honour the same breadth gate as worlds: one too specific to serve
// an arbitrary build stays approved for direct briefs and leaves the
// challenger pool. Falls back to the full approved set rather than returning
@@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul
entry => `${entry.composition.id}#${entry.ticket}`
)).map(entry => entry.composition);
// Area is a preference, not a filter. Requesting an onboarding flow should
// deal onboarding compositions first and top up from the rest of the surface
// rather than deal fewer than three, because the per-area pools are small and
// an unset area is eligible everywhere. A stable partition of an already
// deterministic ranking is still deterministic.
const ordered = area
? [...ranked.filter(composition => composition.area === area),
...ranked.filter(composition => composition.area !== area)]
: ranked;
const families = new Set();
picks = [];
for (const composition of ranked) {
for (const composition of ordered) {
const family = composition.familyId ?? composition.id;
if (families.has(family)) continue;
picks.push(composition);
families.add(family);
if (picks.length >= count) break;
}
for (const composition of ranked) {
for (const composition of ordered) {
if (picks.length >= count) break;
if (!picks.some(pick => pick.id === composition.id)) picks.push(composition);
}