Studio Recommendation Ordering (current)
How the “recommended” studio list is ordered today, end to end. Written againstapps/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 GraphQLstudioRecommendations query backs every surface, but the
surfaceContext decides whether the ranker lane is even eligible.
| Surface | surface | Data path | surfaceContext | Ranker eligible? |
|---|---|---|---|---|
| Create-page asset panel | ASSET_PANEL | WORKSPACE pool (paginated) | WORKSPACE | Yes |
| Prompt strip | PROMPT_STRIP | DRAFT pool | DRAFT | Yes |
| Bulk wizard | BULK_WIZARD | unpooled paginated (product override) | WORKSPACE | Yes |
- Eligibility gate:
isRankerEligibleSurfaceContext→ onlyDRAFTandWORKSPACE(surfaceContext.ts). GENERAL skips the ranker. - The unpooled query maps its impression
surface→ asurfaceContextviaresolveUnpooledSurfaceContext(studioRecommendationMode.ts): the bulk wizard (a product-override path that can’t use the shared pool) requestsWORKSPACEso 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, seestudioRecommenderRankerinapps/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’ssurfaceContextis then chosen byresolveUnpooledSurfaceContext(WORKSPACE for the bulk wizard, GENERAL only for lightweight prompt-typing surfaces like MENTION / SUGGESTION_LIST).
2. The two lanes and how they compose
StudioRecommendationService.getRecommendations builds the legacy list, then
composeWithRanker overlays the ranker:
rankerComposition.ts → composeRankedRecommendations.)
- Ranker lane (top): up to
STUDIO_RECOMMENDER_RANKER_TOP_COUNT = 10studios 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 carriesrecommendationMetadata.lane = 'RANKER'. - Legacy lane (below): every legacy studio not already in the ranker lane,
in the tier order described in §3.
recommendationMetadata.lane = 'LEGACY'.
composeWithRanker returns legacyStudios unchanged).
Note: a ranker-lane card keeps thereasonlabel 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 acategory 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 | _priorityGroup | reason | Definition |
|---|---|---|---|
| P1 | 1 | subcategory | Studio’s bestForSubcategories exact-matches the requested subcategory. |
| P2 | 2 | category | ”Whole-category” studio — no bestForSubcategories (or no subcategory was requested). |
| P3 | 3 | category_other_subcategory | Same 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. |
| P4 | 4 | fallback | Cross-category studios from the whole-catalog fallback pool, by popularity. Only when withFallback and the page isn’t full. |
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:
- Exact size match first —
bestForSizes.includes(size)studios sort ahead of non-matches. - Effective usage count, descending — see the new-studio boost below.
createdAtdescending — deterministic tie-breaker.
New-studio boost
New studios get a synthetic usage floor so they surface before accruing organic usage (getEffectiveUsageCount):
- Window: 5 weeks from
createdAt. boostFactorby age: week1 = 1.0, week2 = 1.0, week3 = 0.75, week4 = 0.5, week5 = 0.25, then 0 (no boost).groupMaxis the max usage of the tier the studio competes in — so a boosted P2 studio floors to the P2 top, never jumping tiers.
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, therecommendationSetId(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-networkquery and later pages viano-cacheaccumulated inusePaginatedNoCacheFetch(ApollofetchMorewould 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):- Ranker lane overlay (most likely). The ranker returns up to 10 studios and
they sit on top of all legacy tiers. A real P1
subcategorystudio the ranker didn’t pick is pushed below those 10, regardless of its subcategory match. Check the card’srecommendationMetadata.lane— if the studios above it areRANKER, this is the cause. - 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.
- Within-tier size ordering (§4.1). Among P1 studios, those with an exact
bestForSizesmatch for the requested size sort ahead — a subcategory studio without the size match drops behind subcategory studios that have it. - Tier misclassification. The studio only lands in P1 if one of its
bestForSubcategoriesexactly (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).
📊 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
| Concern | File |
|---|---|
| Orchestration, tiers, boost, interleave | studioRecommendationService.ts |
| P1/P2/P3 split | categoryStudioSplit.ts |
| Ranker overlay | rankerComposition.ts, rankerClient.ts |
| Ranker eligibility | surfaceContext.ts |
| Cursor / windowing / set id | pagination.ts |
| Reason + metadata types | types.ts |
| Web-core hook (lanes, pagination, cache) | hooks/useStudioRecommendations.ts |
| Surface → data path | hooks/studioRecommendationMode.ts |
| Pool request shaping | providers/studioRecommendationPoolVariables.ts |