# Keyword metrics explained

The full derivation of Demand, Difficulty, Ranking apps and the derived Opportunity score, with every constant and every failure mode.

Four numbers describe a keyword: Demand, Difficulty, Ranking apps and Opportunity. Three are observed. One is derived. This page gives the arithmetic for all four.

[The keyword explorer](/docs/keyword-explorer) covers the screen those numbers appear on. This page covers what is behind them.

Two things to hold onto before the formulas:

> Demand is not search volume. Difficulty is not a competition index bought from anyone. Both are computed from what the public store returns for free, and both are named after the observation they come from, not after the thing you wish you had.

Every number here is for the **US storefront** in **English**. That is the only storefront the keyword collection covers.

## Where the raw material comes from

Two public surfaces feed all four numbers.

| Surface | What it returns | Which numbers use it |
| --- | --- | --- |
| The store's public search endpoint | An ordered list of apps for a query, each with a name, store id, rating and lifetime rating count | Difficulty, Ranking apps |
| The store's search autocomplete | The phrases the store suggests as you type | Demand |

One collection run for one query stores one snapshot: the ordered results, and the time it was observed. Everything below is computed from stored snapshots. Reading a keyword page never calls the store.

The search endpoint returns at most **200** results for one query, so a position is a position within those 200 and nothing is known past them. See `IOS_ASO_RESULT_LIMIT` in `packages/libraries/src/domain/app-catalog/aso-observation.contract.ts`.

## Difficulty

Difficulty answers one question: how established are the apps already sitting on page one?

It uses the lifetime rating counts the store hands back with the search results. An app with two million ratings has been there a long time and has a lot of gravity. An app with forty has not.

```text
page_one       = results where position <= 10 and ratingCount is a number
median_ratings = round(median(page_one.ratingCount))
difficulty     = round( min(1, log10(median_ratings + 1) / 6) × 100 )
```

| Symbol | Meaning | Value or source |
| --- | --- | --- |
| `position <= 10` | Page one. Ten results, matching how the store paginates. | Constant `PAGE_ONE = 10` |
| `ratingCount` | Lifetime rating count the search endpoint returns for that app | Observed, per result |
| `median` | Ordinary median. Even sample sizes average the two middle values. | n/a |
| `+ 1` | Guards `log10(0)` when the median rated app has zero ratings | n/a |
| `6` | The log ceiling. `log10(1,000,000) = 6`, so a million ratings on the median page-one app saturates the scale at 100. | Constant `DIFFICULTY_LOG_CEILING = 6` |
| `× 100` | Puts the score on 0–100 | n/a |
| `min(1, …)` | Clamps. Anything past a million ratings still scores 100. | n/a |

Because the scale is logarithmic, each step of ten in the median rating count costs about 17 points:

| Median page-one rating count | Difficulty |
| --- | --- |
| 10 | 17 |
| 100 | 33 |
| 1,000 | 50 |
| 10,000 | 67 |
| 100,000 | 83 |
| 1,000,000 and up | 100 |

**What is reported alongside it.** The score never travels alone. `medianRatingCount` is the raw median, so you can check the arithmetic. `sampleSize` is how many of the ten page-one results actually carried a rating count, capped at 10. Below three, the interface flags the score as weak evidence.

**When it is null.** If no page-one result carried a rating count, there is no median and difficulty is `null`. It is not zero. Zero would mean "page one is empty of established apps", which is a completely different fact.

**Where it breaks down.** Rating count is a lifetime total, not a current-activity measure. A long-abandoned app that accumulated 200,000 ratings five years ago pushes difficulty up exactly as hard as a thriving one. The median is taken over at most ten values, so one atypical result moves it. And the score measures incumbency, not relevance: a page one of giants that only loosely match the phrase still reads as Hard.

Source: `computeAsoDifficulty` in `packages/libraries/src/domain/app-catalog/aso-metrics.ts`.

## Demand

Demand answers a different question: how much does the store itself push this phrase?

There is no public search-volume number for the App Store. What is public is the autocomplete: type a word and the store offers an ordered list of phrases. That ordering is the store's own judgement about what people search for, and it is free to observe. Demand turns it into a 0–100 score.

Two readings go in.

**Reading one, suggestion rank.** Type only the *first word* of the keyword. If the exact keyword appears in that suggestion list, note its 1-based position.

```text
rank_score = max(40, 100 − (hint_rank − 1) × 10)
```

| Symbol | Meaning | Value or source |
| --- | --- | --- |
| `hint_rank` | 1-based position of the exact keyword in the first-word suggestion list. `null` when it is not suggested. | Observed |
| `100` | Score for the top suggestion | Constant `HINT_RANK_TOP_SCORE = 100` |
| `10` | Cost of each place lower down | Constant `HINT_RANK_STEP = 10` |
| `40` | Floor. Being suggested at all is worth 40, however far down. | Constant `HINT_RANK_FLOOR = 40` |

The floor bites at rank 7 and beyond: rank 1 scores 100, rank 2 scores 90, rank 6 scores 50, and rank 7 onwards all score 40.

**Reading two, variants.** Type the *whole* keyword. Count the suggestions that are longer phrases starting with it, excluding the keyword itself.

```text
variant_score = min(30, variant_count × 6)
```

| Symbol | Meaning | Value or source |
| --- | --- | --- |
| `variant_count` | Suggestions that start with the keyword and are not equal to it | Observed |
| `6` | Points per variant | Constant `VARIANT_SCORE_EACH = 6` |
| `30` | Cap. Five variants max out this reading. | Constant `VARIANT_SCORE_CAP = 30` |

**Combining them.** The two readings are not added. One of them is chosen:

```text
demand = hint_rank is null ? variant_score : rank_score
```

If the store suggests the exact phrase for its first word, the rank score wins outright and the variant count is reported but does not change the number. If it does not, the variant score is the whole score. So a phrase the store never suggests in any form scores **0**, and a phrase it suggests at the top scores **100**.

Comparison is case-insensitive and whitespace-trimmed on both sides.

**What is reported alongside it.** `hintRank` (or `null`), `variantCount`, and `observedAt`, the date of the suggestion snapshot. Suggestion snapshots are reused for **7 days** before they are refetched, so demand is usually the older half of an opportunity score. See `hintFreshnessHours` in `packages/libraries/src/domain/app-catalog/aso-workstation.contract.ts`.

**When it is null.** Demand is `null` when no suggestion snapshot has been stored for the phrase. That is different from a stored snapshot that produced a score of 0, which means the store was asked and offered nothing.

**Where it breaks down.** Autocomplete ordering is a proxy, not a measurement. The store may rank a suggestion highly for reasons that have nothing to do with volume, and it personalises and regionalises its suggestions. The score is also discrete and coarse: it can only take the values 0, and the multiples the two formulas produce. Do not read a 10-point gap between two keywords as a 10 percent difference in anything real.

Source: `computeAsoDemand` in `packages/libraries/src/domain/app-catalog/aso-metrics.ts`.

## Ranking apps

The plainest of the four. It is the number of apps in the stored ranking for that keyword.

It is a count of what was collected, not an estimate of how many apps compete for the phrase. The store caps one response at 200 results, so 200 means "at least 200", not "exactly 200". A count well below 200 means the store returned fewer results, not that collection was truncated.

Under the count, a distribution buckets the **top 20** results by order of magnitude of rating count: `0`, `1–9`, `10–99`, `100–999`, `1K–10K`, `10K–100K`, `100K–1M`, `1M+`. That shape tells you something the median cannot: whether page one is one giant among newcomers, or twenty giants.

Sources: `RATING_BUCKET_APP_LIMIT` and `BUCKET_LABELS` in `apps/peekpanda/components/aso/rating-buckets.ts`.

On an app's own profile the ranking list is capped at **25** apps rather than 200, and when the app sits below position 25 the list is stitched: the head of the ranking, then the app with two neighbours either side, so you always see both the leaders and its actual neighbourhood. That is `capIosAsoAppsInRanking` in `packages/libraries/src/domain/app-catalog/aso-profile.ts`.

## Opportunity

Opportunity is the only derived number of the four. It is never collected, never stored as an observation, and never computed in the interface.

```text
opportunity = round( demand × (100 − difficulty) / 100 )
```

| Symbol | Meaning | Range |
| --- | --- | --- |
| `demand` | The demand score above | 0–100 |
| `difficulty` | The difficulty score above | 0–100 |
| `100` | The scale both inputs share | Constant `SCALE = 100` |

Read it as *how much the store pushes this phrase, discounted by how locked down page one already is*. A phrase with demand 90 behind a page one of million-rating apps scores 0. The same phrase with an unrated page one scores 90.

**It is null when either input is null.** Not zero. A keyword with no demand reading and a keyword with no demand are different facts, and the score refuses to collapse them. The tile reads *Not collected · Needs a demand and a difficulty reading* instead.

**It is only as fresh as the older of its two inputs.** Demand snapshots last a week; search snapshots last a day. In practice the demand reading is usually the limiting one.

**Where it breaks down.** It is a product of two proxies, so it inherits both sets of limits and compounds them. It says nothing about whether your app is a good match for the phrase, whether the phrase converts, or whether the traffic is worth having. Treat it as a shortlisting device, not a verdict.

Source: `computeAsoOpportunity` in `packages/libraries/src/domain/app-catalog/aso-opportunity.ts`. One function, called everywhere the score appears, so the tile, the workspace list and the keyword table can never disagree.

## Worked example

A keyword where the store suggests the exact phrase second for its first word, offers three longer variants, and page one has median rating count 4,300 across nine rated results.

```text
rank_score    = max(40, 100 − (2 − 1) × 10)          = 90
variant_score = min(30, 3 × 6)                        = 18
demand        = 90                    (hint_rank is not null, so rank_score wins)

difficulty    = round(min(1, log10(4301) / 6) × 100)
              = round(min(1, 3.634 / 6) × 100)        = 61

opportunity   = round(90 × (100 − 61) / 100)          = 35
```

That reads as Demand **High**, Difficulty **Medium**, Opportunity **Worth a try**.

## The words next to the scores

Every 0–100 score gets a word so the number is never the only thing to read. Demand and difficulty share one ladder; opportunity, being a product of two scores, sits lower and has its own.

| Score | Demand | Difficulty | Opportunity |
| --- | --- | --- | --- |
| 67 and up | High | Hard | Wide open (50+) |
| 34 to 66 | Some | Medium | Worth a try (25–49) |
| below 34 | Low | Easy | Crowded (below 25) |

Cut points live in `apps/peekpanda/components/aso/opportunity-words.ts`.

## What these numbers are not

- **Not native App Store rank.** Every position is the position observed in the public search response. It carries the label `observed_search_api_position` through the whole contract so it can never be quietly relabelled.
- **Not search volume.** No public store surface exposes it, and PeekPanda does not model it.
- **Not a 5–100 popularity score.** The store's advertising platform publishes one. PeekPanda is not connected to it, so that number does not exist here.
- **Not multi-country.** One storefront, US, in English.

## Related

- [The keyword explorer](/docs/keyword-explorer)
- [Refreshing keywords](/docs/refreshing-keywords)
- [Freshness and source dates](/docs/freshness-and-source-dates)
- [Honest unknowns](/docs/honest-unknowns)
