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

# Collection & Rankings — Behavior Contract

> Every behavior the favorites, dislikes, collection statuses and strain-ranking features must have, and the test that proves each one.

## What this feature is

Collection is the user's personal library of strains. From a strain card, a
strain page, the browse results, or the home screen, a user can **favorite** a
strain, **dislike** it, or mark it **owned**, **tasted**, or **want to try** — and
attach private notes to any of those. The Collection screen gathers all of it into
six tabs with sorting, search, and six card layouts. Once three strains are marked
owned or tasted, the user can rank them by **head-to-head A/B comparison**: pick
one of two strains ten times per session, and an ELO rating produces a personal
leaderboard. Collection is also an input to other features — it feeds the Safari
shopping extension's slug lists, two achievement tracks, Professor High's answers,
and the Stats tab.

## How to read the contract

Every case ID below is a live test. Cases built during the feature-test programme
carry their ID in the test title, so `grep 'COL-063'` finds the assertion. A
handful of rows point at coverage that predates the programme; those are cited by
ID and file, and the test is found by its plain-English name in that file.

## The behavior contract

### Favoriting a strain

| Behavior                                                                                                                                                       | Verified by               | Tier(s) | Where                                                   |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------- | ------------------------------------------------------- |
| Tapping the heart on a strain with no library row creates one, favorited, stamped with how it was favorited and when                                           | COL-001                   | A       | `collection.featuretests.test.ts`                       |
| Tapping the heart twice returns the strain to neutral and leaves exactly one row behind — the row survives because the user's notes and tags live on it        | COL-002, COL-013          | A       | `collection.featuretests.test.ts`                       |
| A strain that has been un-favorited stops counting toward the library — the surviving row is a husk, not a collected strain                                    | COL-160, COL-162          | A       | `collection.featuretests.test.ts`                       |
| Notes and tags written while a strain was favorited survive an unfavorite → refavorite cycle                                                                   | COL-003                   | A       | `collection.featuretests.test.ts`                       |
| Setting a favorite that is already set changes nothing, and un-favoriting a strain the user never collected must not add it to the library                     | COL-005, COL-006          | A       | `collection.featuretests.test.ts`                       |
| A favorite write never downgrades a strain's stored name to a placeholder                                                                                      | COL-007, COL-168          | A       | `collection.featuretests.test.ts`                       |
| Favorite and dislike are mutually exclusive in both directions — favoriting a disliked strain clears the dislike, and vice versa                               | COL-004, COL-008, COL-021 | A       | `collection.featuretests.test.ts`, `collection.test.ts` |
| Every favorite toggle sends the strain's real name, type and slug — never `'Unknown'` — and warns rather than inventing data when metadata is genuinely absent | COL-009, COL-010          | B       | `useCollectionPreferences.featuretests.test.tsx`        |
| A favorite tap on the home screen is refused before the mutation is sent when the owner key does not match or the device is offline                            | COL-011                   | B       | `useCollectionPreferences.featuretests.test.tsx`        |
| The heart's optimistic fill reverts when the mutation rejects                                                                                                  | COL-012                   | B       | `CollectionScreens.test.tsx`                            |
| Editing a favorite's notes or tags is rejected on a strain that is not favorited, and an empty tag array clears the tags                                       | COL-014, COL-015          | A       | `collection.featuretests.test.ts`                       |

### Disliking a strain

| Behavior                                                                                                                                                                                | Verified by      | Tier(s) | Where                                                                               |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | ----------------------------------------------------------------------------------- |
| Disliking a strain records it in both the status list and the preference state, so no surface can disagree about it                                                                     | COL-020          | A       | `collection.featuretests.test.ts`                                                   |
| A dislike recorded by an older app version is visible to **every** dislike surface — the Disliked tab, the strain page, stats, the extension, and Professor High all read one predicate | COL-023, COL-026 | A, B    | `collection.featuretests.test.ts`, `useCollectionPreferences.featuretests.test.tsx` |
| Removing a dislike must never clear an unrelated favorite                                                                                                                               | COL-022          | A       | `collection.featuretests.test.ts`                                                   |
| Disliking an already-disliked strain changes nothing, and a dislike reason is rejected on a strain that is not disliked                                                                 | COL-024, COL-025 | A       | `collection.featuretests.test.ts`                                                   |
| Dislike statistics average potency only over the strains that actually carry THC/CBD values                                                                                             | COL-027          | A       | `collection.featuretests.test.ts`                                                   |

### Marking owned, tasted, or want-to-try

| Behavior                                                                                                                                                                                                                         | Verified by               | Tier(s) | Where                                           |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------- | ----------------------------------------------- |
| Marking a strain owned initializes its ranking state; marking it want-to-try does not                                                                                                                                            | COL-030, COL-031          | A       | `collection.featuretests.test.ts`               |
| Adding a second experienced status to a strain keeps its single existing rating rather than resetting it                                                                                                                         | COL-032                   | A       | `collection.featuretests.test.ts`               |
| Adding a status the strain already has is a no-op; removing one it does not have still records the interaction; and a status write may only link a stash item the caller owns                                                    | COL-034, COL-035, COL-036 | A       | `collection.featuretests.test.ts`               |
| Losing the last owned/tasted status tears down that strain's ranking state and comparison history                                                                                                                                | COL-033                   | A       | `collection.test.ts`                            |
| A collection row created by a stash purchase carries full preference provenance, so it is reachable by every favorite and dislike projection                                                                                     | COL-037, COL-038          | A       | `collection.featuretests.test.ts`               |
| Choosing "owned" in the edit sheet reroutes the user to the stash-add flow instead of writing a bare status                                                                                                                      | COL-039                   | B       | `CollectionScreens.test.tsx`                    |
| Removing the last owned/tasted status warns the user that ranking data will be lost before it happens                                                                                                                            | COL-040                   | B       | `CollectionScreens.test.tsx`                    |
| The strain-page bottom banner chooses between creating a row and updating one, forwards the full strain identity, sends an absent slug as absent rather than empty, and turns a rejected write into a message instead of a crash | COL-041                   | B       | `collection-bottom-banner.featuretests.test.ts` |
| The banner's optimistic status reconciles with the server value once it arrives                                                                                                                                                  | COL-042                   | B       | `CollectionScreens.test.tsx`                    |

### Notes and removing a strain

| Behavior                                                                                                                                                                 | Verified by               | Tier(s) | Where                             |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | ------- | --------------------------------- |
| Notes round-trip intact, including an empty string and the full 500 characters                                                                                           | COL-051                   | A       | `collection.featuretests.test.ts` |
| The notes editor enforces the 500-character cap and shows the remaining count                                                                                            | COL-052                   | B       | `CollectionScreens.test.tsx`      |
| Notes cannot be written onto another user's row                                                                                                                          | COL-050                   | A       | `collection.featuretests.test.ts` |
| Removing a strain deletes its row and every comparison naming it, then re-ranks the survivors so ranks stay contiguous from 1                                            | COL-053                   | A       | `collection.featuretests.test.ts` |
| Removing a strain that is not in the collection reports not-found, removing another user's row is refused, and removing a favorited strain decrements the favorite count | COL-054, COL-055, COL-056 | A       | `collection.featuretests.test.ts` |
| Removal always asks for confirmation first                                                                                                                               | COL-057                   | B       | `CollectionScreens.test.tsx`      |

### Ranking strains head to head

| Behavior                                                                                                                                                                                                 | Verified by               | Tier(s) | Where                                                                     |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------- | ------------------------------------------------------------------------- |
| Ranking unlocks at three eligible strains, and want-to-try strains never count toward that three                                                                                                         | COL-060, COL-061          | A       | `collection.featuretests.test.ts`                                         |
| The ranking entry point appears only once three experienced strains exist                                                                                                                                | COL-143                   | B       | `CollectionRankings.test.tsx`                                             |
| Starting a session returns a fresh session id and its comparison budget, and the screen auto-starts a session exactly once per mount                                                                     | COL-062, COL-089          | A, B    | `collection.featuretests.test.ts`, `CollectionRankings.test.tsx`          |
| Picking a winner moves both strains' ratings symmetrically at the new-strain volatility factor                                                                                                           | COL-063                   | A       | `collection.featuretests.test.ts`                                         |
| Calling two equally-rated strains a tie changes neither rating but counts the comparison; an upset moves ratings further than an expected result; and no outcome depends on which strain was shown first | COL-064                   | A       | `collectionHelpers.test.ts`                                               |
| Skipping a pair records the skip but moves no rating and no win/loss counter                                                                                                                             | COL-065, COL-088          | A, B    | `collection.featuretests.test.ts`, `strain-rankings.featuretests.test.ts` |
| Every comparison is stored under one normalized pair key with the result flipped to match, so the same pair is never stored two ways                                                                     | COL-066                   | A       | `collection.featuretests.test.ts`                                         |
| A comparison is refused when either strain belongs to another user, when either has never been owned or tasted, or when a strain is compared against itself                                              | COL-067, COL-068, COL-069 | A       | `collection.featuretests.test.ts`                                         |
| Session counters track completed and skipped comparisons separately and cannot be moved through another user's session id                                                                                | COL-070, COL-071          | A       | `collection.featuretests.test.ts`                                         |
| The next pair never repeats a pair already decided, and a skipped pair is re-offered in a later session but not again within the same one                                                                | COL-073, COL-074          | A       | `collection.featuretests.test.ts`, `collection.test.ts`                   |
| Pairing prefers strains the user has never compared, then the least-calibrated ones, and breaks score ties deterministically                                                                             | COL-077, COL-078          | A       | `collection.featuretests.test.ts`                                         |
| Pairing returns nothing — rather than an error or a repeat — when the pair space is exhausted or fewer than two strains are eligible                                                                     | COL-075, COL-076          | A       | `collection.featuretests.test.ts`                                         |
| Progress reports a status ladder whose remaining-pairs count never goes negative, and ignores comparisons naming a strain that has since been removed                                                    | COL-079, COL-080          | A       | `collection.featuretests.test.ts`, `collectionHelpers.test.ts`            |
| The leaderboard shows only strains that have actually been compared, numbered contiguously from 1, and agrees with the collection's own ranked count                                                     | COL-081                   | A       | `collection.featuretests.test.ts`                                         |
| Strains on identical ratings share a rank and the next rank skips accordingly                                                                                                                            | COL-082                   | A       | `collectionHelpers.test.ts`                                               |
| The leaderboard honours a requested length limit                                                                                                                                                         | COL-083                   | A       | `collection.featuretests.test.ts`                                         |
| The My Rankings list drops uncompared rows, sorts by rank, tolerates an empty payload, and treats a missing or negative comparison count as uncompared                                                   | COL-093                   | B       | `my-rankings.featuretests.test.ts`                                        |
| The podium renders second-first-third, and the compare shortcut hides once no pairs remain                                                                                                               | COL-094, COL-095          | B       | `CollectionRankings.test.tsx`                                             |
| Ending a session stamps its end time and is safe to call twice; it reveals nothing about another user's session                                                                                          | COL-084, COL-085          | A       | `collection.featuretests.test.ts`                                         |
| The comparison screen's four verdicts map to the right outcomes and go inert while a comparison is in flight; not-enough-strains, session-complete and loading each render their own state               | COL-086, COL-092          | B       | `CollectionRankings.test.tsx`                                             |
| The session ends at exactly ten comparisons; a failed comparison or a failed end reports failure rather than a false completion                                                                          | COL-087                   | B       | `strain-rankings.featuretests.test.ts`                                    |
| The ten-comparison budget is declared identically in all three places that declare it                                                                                                                    | COL-091                   | B       | `strain-rankings.featuretests.test.ts`                                    |
| Switching accounts clears in-flight session state, and ending with no session is a no-op                                                                                                                 | COL-090                   | B       | `useCollection.featuretests.test.tsx`                                     |

### Browsing the collection

| Behavior                                                                                                                                                                                                                                           | Verified by                                 | Tier(s) | Where                                                                                                                        |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
| The collection lists newest first and pages correctly, and a filtered listing returns full pages rather than short ones                                                                                                                            | COL-100, COL-101                            | A       | `collection.featuretests.test.ts`                                                                                            |
| A collection read is refused when the owner key does not match the signed-in user, and a favorites page contains only the caller's rows                                                                                                            | COL-102, COL-105                            | A       | `collection.featuretests.test.ts`                                                                                            |
| An unauthenticated read returns an empty collection rather than throwing, so a screen mid-sign-in never renders an error                                                                                                                           | COL-103                                     | A       | `collection.featuretests.test.ts`                                                                                            |
| The unpaginated listing sorts newest-first and honours a status filter                                                                                                                                                                             | COL-104                                     | A       | `collection.featuretests.test.ts`                                                                                            |
| A favorite carries the moment it was favorited in its own field, distinct from when its row was created, and a fully drained favorites listing stays in newest-first order                                                                         | COL-106, COL-106b                           | A       | `collection.featuretests.test.ts`                                                                                            |
| Favorites hooks drain every page automatically, and skip the drain entirely while authentication has not settled                                                                                                                                   | COL-121                                     | B       | `useCollectionPreferences.featuretests.test.tsx`                                                                             |
| Paginated mode reports whether more pages exist and whether one is loading; "load more" is a no-op in unpaginated mode and is fenced while offline                                                                                                 | COL-122, COL-123                            | B       | `useCollection.featuretests.test.tsx`                                                                                        |
| Searching favorites matches name, notes and tags case-insensitively, clamps the requested limit, and is honest about only searching a recent window                                                                                                | COL-107, COL-108, COL-109                   | A       | `collection.featuretests.test.ts`                                                                                            |
| Filtering favorites by tag matches the whole tag, not a substring of one                                                                                                                                                                           | COL-110                                     | A       | `collection.featuretests.test.ts`                                                                                            |
| Favorite statistics aggregate by strain type, average potency and the five most-used tags; collection statistics count every bucket the screens display                                                                                            | COL-111, COL-112                            | A       | `collection.featuretests.test.ts`                                                                                            |
| Asking for the status of a strain that is not in the collection returns the empty shape, not an error, and the home preview returns only the three fields it renders                                                                               | COL-113, COL-114                            | A       | `collection.featuretests.test.ts`                                                                                            |
| The My Strains list counts only completed orders, keys a line with no strain id by its name, converts every purchase unit to grams, sorts ranked strains first then alphabetically, and returns nulls rather than absent fields                    | COL-116, COL-117, COL-118, COL-119, COL-120 | A       | `collection.test.ts`, `collection.featuretests.test.ts`                                                                      |
| The six tabs filter to their own status, and the manage-favorites route opens on the Favorites tab                                                                                                                                                 | COL-130, COL-131                            | B       | `collection-management.helpers.test.ts`                                                                                      |
| Sorting by rank puts unranked strains last; sorting by name is A–Z                                                                                                                                                                                 | COL-132                                     | B       | `collection-management.helpers.test.ts`                                                                                      |
| The chosen card layout persists across mounts, maps to its own column count, drops to two columns on a narrow or large-text screen, falls back to the default when the stored value is unreadable, and warns without crashing when the write fails | COL-134, COL-135, COL-136, COL-137          | B       | `CollectionScreens.test.tsx`, `collectionCardVariantPreferenceStorage.test.ts`, `collection-management.featuretests.test.ts` |
| A cold offline launch shows the controls, not a false empty state; retry is blocked while offline; the genuine empty state appears only after an online resolve                                                                                    | COL-138, COL-139, COL-140                   | B       | `CollectionColdOffline.test.tsx`                                                                                             |
| "No results for your search" is a different state from "your collection is empty"                                                                                                                                                                  | COL-141                                     | B       | `CollectionScreens.test.tsx`                                                                                                 |
| Every write action is blocked while the screen reports that actions are unavailable, and a user without the entitlement sees the mock collection behind the access gate                                                                            | COL-142, COL-145                            | B       | `CollectionScreens.test.tsx`                                                                                                 |
| Switching accounts never shows the previous account's rows                                                                                                                                                                                         | COL-144                                     | B       | `CollectionColdOffline.test.tsx`                                                                                             |

### Identifying the right strain

| Behavior                                                                                                                                                                         | Verified by      | Tier(s) | Where                                         |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | --------------------------------------------- |
| A strain's cache key prefers its slug and lowercases and trims either identifier form                                                                                            | COL-150          | B       | `strain-detail-identity.featuretests.test.ts` |
| A deleted slug must not silently resolve to a different strain with the same name; a slug that resolves short-circuits the name fallback; a slug-less row still resolves by name | COL-151          | B       | `strain-detail-queries.featuretests.test.ts`  |
| Tapping a collection row that has no slug navigates by strain id, and a strain page that 404s shows an error rather than spinning forever                                        | COL-152, COL-153 | B       | `CollectionScreens.test.tsx`                  |
| A row whose strain id is not a UUID still round-trips through every collection surface                                                                                           | COL-154          | A       | `collection.featuretests.test.ts`             |
| Favoriting the same strain under two different ids creates two library rows, but the extension payload still lists its slug once                                                 | COL-155          | A       | `collection.featuretests.test.ts`             |

### Everything downstream of the collection

| Behavior                                                                                                                                                                              | Verified by        | Tier(s) | Where                                             |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------- | ------------------------------------------------- |
| The Safari extension's slug lists skip rows that carry no slug, de-duplicate, and reflect a favorite made in the same write chain                                                     | COL-115, COL-165   | A       | `collection.featuretests.test.ts`                 |
| The extension payload is sorted and bounded to the native contract's length                                                                                                           | COL-166            | B       | `useExtensionSync.test.ts`                        |
| Favoriting a strain increments the favorite count by exactly one                                                                                                                      | COL-160            | A       | `collection.featuretests.test.ts`                 |
| One collection write arms exactly one history event no matter how large the library, and a twenty-write burst settles to a projection that matches source                             | COL-177a, COL-177b | A       | `collection.featuretests.test.ts`                 |
| The Collection Curator card copy matches the thresholds the rules actually use                                                                                                        | COL-164            | B       | `achievements-v2.collection.featuretests.test.ts` |
| Deleting an account removes every collection row, comparison and ranking session, and the account's reported favorite and dislike counts match what the collection projections return | COL-169, COL-170   | A       | `collection.featuretests.test.ts`                 |

## What the live tier proves end-to-end

Tier C runs against the deployed staging backend with a brand-new, really-signed-in
identity per test, and deletes it afterwards.

* A favorite survives the full round trip — mutation, deployed validators, and read
  back through the deployed queries (**COL-180**).
* All twenty-three deployed collection queries accept the exact argument shapes the
  app's hooks send (**COL-181**).
* A complete ranking session runs start → ten comparisons → automatic end against
  the real deployment (**COL-182**).
* The Revyl ranked-collection seed is idempotent on staging and is statically proven
  to reject production (**COL-183**).
* A collection write reaches the achievements engine through the real debounce, not
  a test-forced flush (**COL-184**).
* An order becomes a stash item which becomes an `owned` collection row, across three
  features on one deployment (**COL-185**).
* One user's collection is invisible to another, and a mismatched owner key is
  rejected by the deployed functions rather than only by local code (**COL-186**,
  **COL-187**).

## Deliberate gaps & conditions

* **The ten-comparison session cap is advisory.** The backend accepts an eleventh
  comparison; the cap lives only in the three client declarations that COL-091 keeps
  in agreement. COL-072 pins "advisory only" as the contract *as a characterization* —
  if that decision reverses, COL-072 must be rewritten, not deleted.
* **Ranking-session counters have no reader.** Nothing in the app reads a session's
  completed/skipped counts or its end time, so COL-182 can prove only half of the
  session contract on a real deployment. Either the columns get a reader or they are
  dead; the decision is open.
* **Sort choice is deliberately not persisted** across mounts. No test asserts it
  (COL-133 is deferred) because a passing characterization here would only make a
  future product change look like a regression.
* **There are no bulk operations.** Multi-select and bulk delete do not exist in this
  feature, so nothing in the contract covers them.
* **Curator thresholds, per-type variety counters, and the Professor High favorite
  path** are contracted here but proved in the achievements and users suites, so the
  same rule is not asserted twice against two copies of the thresholds
  (COL-161 → ACH-013, COL-163 → ACH-009/ACH-011, COL-167 → CHAT-014).
* **Tier C skips cleanly without credentials**, naming exactly what is missing. It is
  never part of `pnpm test`, `pnpm check`, or a cached Turbo run.

## Where to extend

A new favorite/dislike/status/ranking rule gets its Tier A case in
`apps/mobile/convex/tests/collection.featuretests.test.ts`; a pure helper rule goes
next to its helper as a `*.featuretests.test.ts`. A new hook argument or screen state
gets a Tier B case in the co-located `*.featuretests.test.tsx` for that hook, or in
`apps/mobile/test/ui/features/CollectionScreens.test.tsx` /
`CollectionRankings.test.tsx` for rendering. Only reach for
`apps/mobile/test/integration/suites/collection.integration.test.ts` when the claim is
about the **deployed** backend. Then add the row to the master matrix — see
[Mobile Feature Test Platform](/planning/testing/feature-test-platform) for naming,
tags, and the tier decision.
