> ## Documentation Index
> Fetch the complete documentation index at: https://kive.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Studio recommendation ordering

# 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.

| 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` → 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.ts` → `composeRankedRecommendations`.)

* **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   | `_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.                                                                                                                                                                                                                                                                                                                   |

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 first** — `bestForSizes.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

| 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` |
