Skip to main content

Studio Recommendation Ordering (current)

How the “recommended” studio list is ordered today, end to end. Written against apps/graphql/src/graph/studio/helpers/studioRecommendation/* and the web-core hooks under features/generation/prompt/. The short version: the final list is two lanes stacked — a machine-learned ranker lane on top, then a rule-based legacy tier lane below it. The legacy lane is where “subcategory / category / fallback” tiers live. The ranker lane, when active, can push a genuine subcategory studio down the page — that is the most common cause of “the subcategory studio is lower than it should be.”

1. Where it runs and whether the ranker is active

The same GraphQL studioRecommendations query backs every surface, but the surfaceContext decides whether the ranker lane is even eligible.
SurfacesurfaceData pathsurfaceContextRanker eligible?
Create-page asset panelASSET_PANELWORKSPACE pool (paginated)WORKSPACEYes
Prompt stripPROMPT_STRIPDRAFT poolDRAFTYes
Bulk wizardBULK_WIZARDunpooled paginated (product override)WORKSPACEYes
  • Eligibility gate: isRankerEligibleSurfaceContext → only DRAFT and WORKSPACE (surfaceContext.ts). GENERAL skips the ranker.
  • The unpooled query maps its impression surface → a surfaceContext via resolveUnpooledSurfaceContext (studioRecommendationMode.ts): the bulk wizard (a product-override path that can’t use the shared pool) requests WORKSPACE so it runs the same ranker + tier ordering as the asset panel. Its input still comes from the selected products (override), so it isn’t a byte-identical slate — same algorithm, product-specific candidates.
  • The ranker is also globally gated by config studioRecommenderRanker.enabled (default true, see studioRecommenderRanker in apps/graphql/src/config.ts).
  • Mode resolution lives in studioRecommendationMode.ts (resolveRecommendationMode): ASSET_PANEL + paginated → WORKSPACE pool, PROMPT_STRIP → DRAFT pool, everything else → unpooled query. The unpooled query’s surfaceContext is then chosen by resolveUnpooledSurfaceContext (WORKSPACE for the bulk wizard, GENERAL only for lightweight prompt-typing surfaces like MENTION / SUGGESTION_LIST).
So the ranker lane is ON for the create-page asset panel, the prompt strip, and the bulk wizard. The bulk wizard’s candidates are still scoped to the selected products (its override input); only the ordering algorithm matches the asset panel.

2. The two lanes and how they compose

StudioRecommendationService.getRecommendations builds the legacy list, then composeWithRanker overlays the ranker:
finalList = [ ...rankerStudios, ...legacyStudios.filter(not in ranker) ]
(rankerComposition.tscomposeRankedRecommendations.)
  • Ranker lane (top): up to STUDIO_RECOMMENDER_RANKER_TOP_COUNT = 10 studios returned by the ranker service (rankerClient.ts), in the ranker’s own score order. Deduped and validated (must be Admin, published, not deleted, isProductShotTemplate). Each carries recommendationMetadata.lane = 'RANKER'.
  • Legacy lane (below): every legacy studio not already in the ranker lane, in the tier order described in §3. recommendationMetadata.lane = 'LEGACY'.
If the ranker is disabled, ineligible, times out, errors, or returns nothing, the list is legacy only (composeWithRanker returns legacyStudios unchanged).
Note: a ranker-lane card keeps the reason label of its legacy twin (resolveRankerStudios: reason: legacyStudio?.reason ?? 'general'). So a card tagged “subcategory” can be sitting in the ranker lane, and a different genuine subcategory studio the ranker did not pick can sit far below it.

3. Legacy tier order (P1 → P4)

Only relevant when a category is supplied. Pool is fetched from Firestore (bestForCategories array-contains category, Admin/published/template), plus a parallel fetch of recently-created category studios (for the new-studio boost), plus a parallel fallback pool (whole catalog). The category pool is split by splitStudiosBySubcategory (categoryStudioSplit.ts) into three tiers, and the fallback pool forms the fourth:
Tier_priorityGroupreasonDefinition
P11subcategoryStudio’s bestForSubcategories exact-matches the requested subcategory.
P22category”Whole-category” studio — no bestForSubcategories (or no subcategory was requested).
P33category_other_subcategorySame category but only different non-empty subcategories of the requested category (e.g. a fashion:shoes studio for a fashion:woman product). Subcats belonging to a studio’s other categories (sports:balls on a fashion request) don’t count — bestForSubcategories is a flat set across all of a studio’s categories, so the split is scoped by categorySubcategories (from PRODUCT_CATEGORIES). Demoted, not dropped.
P44fallbackCross-category studios from the whole-catalog fallback pool, by popularity. Only when withFallback and the page isn’t full.
Strict order: all P1 before all P2 before all P3 before all P4 (sortStudiosByPriority, comparator step 1). No category at all → the “universal” path, everything is reason: 'general' ordered purely by popularity. P3 is new in this PR. Before, a same-category / wrong-subcategory studio fell into P2 (category). Now it is split out and demoted below whole-category studios. That is a demotion of those studios — it does not move any P1 subcategory studio.

4. Ordering within a tier

Inside one tier, the comparator (sortStudiosByPriority) applies, in order:
  1. Exact size match firstbestForSizes.includes(size) studios sort ahead of non-matches.
  2. Effective usage count, descending — see the new-studio boost below.
  3. createdAt descending — deterministic tie-breaker.

New-studio boost

New studios get a synthetic usage floor so they surface before accruing organic usage (getEffectiveUsageCount):
effectiveUsage = max(actualUsageCount, groupMaxUsage * boostFactor)
  • Window: 5 weeks from createdAt.
  • boostFactor by age: week1 = 1.0, week2 = 1.0, week3 = 0.75, week4 = 0.5, week5 = 0.25, then 0 (no boost).
  • groupMax is the max usage of the tier the studio competes in — so a boosted P2 studio floors to the P2 top, never jumping tiers.
Effective usage is precomputed once per studio (not inside the comparator) so a week boundary crossing mid-sort can’t break sort transitivity.

5. Multiple categories (interleave)

When a product/brand spans several category+subcategory pairs, getRecommendationsForCategories runs getRecommendations per pair (capped at MAX_RECOMMENDATION_CATEGORIES = 4) and round-robins the per-pair lists column-major (interleaveStudiosById): 1st of pair A, 1st of pair B, 2nd of pair A, … deduped by id, capped at first. Consequence: a pair’s P1 subcategory studio is not guaranteed the top slot — it’s interleaved with the other pairs’ top studios. This is a second way a subcategory studio can appear lower than “first.”

6. Pagination and set id

  • Page size on the create list is STUDIO_RECOMMENDATIONS_COUNT = 60 (pool page size also 60).
  • The ranking is computed over the whole in-memory pool and windowed by an opaque cursor (pagination.ts): the cursor carries the position offset, the recommendationSetId (minted on page 0, shared across the scroll session for analytics joins), and the ranker recommendations so later pages keep the same ranker lane.
  • Ranker fetch only happens on page 0; subsequent pages reuse the ranker ids carried in the cursor (getRankerRecommendations: SKIPPED_CURSOR / REUSED_CURSOR).
  • Web-core fetches page 0 via a watched cache-and-network query and later pages via no-cache accumulated in usePaginatedNoCacheFetch (Apollo fetchMore would be clobbered by cache broadcasts).

7. Contextual thumbnails (ordering-adjacent)

Independent of position: getContextualRecommendationThumbnails swaps each card’s image to the best category/subcategory-specific recommendationThumbnails (APPROVED only), falling back to the studio default. Affects the picture, not the rank.

8. Why a subcategory studio can appear lower than expected

Ranked by likelihood on the create-page asset panel (ranker ON):
  1. Ranker lane overlay (most likely). The ranker returns up to 10 studios and they sit on top of all legacy tiers. A real P1 subcategory studio the ranker didn’t pick is pushed below those 10, regardless of its subcategory match. Check the card’s recommendationMetadata.lane — if the studios above it are RANKER, this is the cause.
  2. Multi-category interleave (§5). If the product resolves to more than one category/subcategory pair, the subcategory studio is round-robined with other pairs’ leaders, so it won’t be first.
  3. Within-tier size ordering (§4.1). Among P1 studios, those with an exact bestForSizes match for the requested size sort ahead — a subcategory studio without the size match drops behind subcategory studios that have it.
  4. Tier misclassification. The studio only lands in P1 if one of its bestForSubcategories exactly (trim+lowercase) equals the requested subcategory. A near-miss string, or a studio that carries only other subcategories of the requested category, lands in P3 (category_other_subcategory) and correctly sorts below whole-category P2 studios — this is the behavior this PR introduced. A multi-category studio whose subcats all belong to its other categories stays in P2 for this category (the split ignores out-of-scope subcats).
To diagnose a specific case, the GraphQL logs emit the split (📊 Studio split results) and the per-studio reason + recommendationMetadata.lane are on every returned card. If the studio in question shows reason: 'subcategory' but sits low, look at whether the cards above it are lane: 'RANKER' (cause #1) or reason: 'subcategory' with a size match (cause #3).

Key files

ConcernFile
Orchestration, tiers, boost, interleavestudioRecommendationService.ts
P1/P2/P3 splitcategoryStudioSplit.ts
Ranker overlayrankerComposition.ts, rankerClient.ts
Ranker eligibilitysurfaceContext.ts
Cursor / windowing / set idpagination.ts
Reason + metadata typestypes.ts
Web-core hook (lanes, pagination, cache)hooks/useStudioRecommendations.ts
Surface → data pathhooks/studioRecommendationMode.ts
Pool request shapingproviders/studioRecommendationPoolVariables.ts