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

# Measurement

# What rekkal measures

The product side of the repo: which engines rekkal queries, what surface those queries represent, how often they run, and the quotas that bound them. The plan numbers here are the ones encoded in [`config.ts`](../config.ts), which is the source of truth — this file explains them.

## Engines

v1 measures exactly three engines:

| Engine     | What rekkal queries                       |
| ---------- | ----------------------------------------- |
| ChatGPT    | OpenAI's API with web search enabled      |
| Perplexity | Perplexity Sonar                          |
| Gemini     | Google's API with Google Search grounding |

`config.engines` in [`config.ts`](../config.ts) is the single source of truth. The features grid, the coverage marquee, the FAQ, the OG image, and the SEO metadata all render from it, so adding or dropping an engine is one edit there — `config.test.ts` fails if any component under `components/marketing/` or the OG image hardcodes an engine name instead, and the engine count in the coverage heading is derived too. The prose lists that cannot import it (this file, the README, and `llms*.txt`) still have to be changed in the same pass.

The per-plan engine bullets on the pricing cards are the deliberate exception: each plan renders its own `engines` list, and the free plan covers Perplexity only (see [Quotas](#quotas)). A plan naming fewer engines than the three above is correct, not drift.

## Measurement surface

rekkal queries each provider's own API with grounding turned on, and reports what those APIs return. That is the whole measurement surface: there is no browser automation and no scraping of a consumer chat UI.

An answer from a provider's API can differ from what the same provider's consumer app returns for the same question — different retrieval, different personalisation, different model routing. So rekkal's numbers are a consistent measure of the grounded API surface, not a transcript of what any individual user sees in the app. The site says this in the same words on the pricing section and in the FAQ; keep those in step with this file.

## Cadence

Both plans refresh weekly. This is a design decision, not a limitation to apologise for: a week of runs is enough sample to say whether something actually moved, where a daily figure mostly reports sampling noise.

Consequently, every displayed metric carries sample-size awareness — a confidence band on the value, suppression of deltas the sample does not support, and an explicit "no comparison" state when there is no prior period, rather than a delta computed against nothing.

## Quotas

rekkal pays for every engine query; customers supply no API keys. Quotas are therefore enforced in code, not implied by fair-use wording, because margin is directly exposed to vendor pricing.

`config.limits` in [`config.ts`](../config.ts) is the enforced ceiling, and `lib/runner/limits.ts` is the only place that reads it. `config.plans.*.features` is marketing copy: when the two disagree, `config.limits` is the truth and the copy is the bug.

The bullets that quote a limit are rendered from it rather than retyped, so they cannot disagree in the first place: the engine list through `formatEngineNames`, the tracked-competitor allowance through `formatTrackedCompetitors`, which turns Pro's `null` into "Unlimited" with no sentinel number. `config.test.ts` fails if a plan's bullet stops following `config.limits`. The remaining bullets are hand-written prose and can still drift.

|                                     | Free       | Pro (EUR 99/mo)             |
| ----------------------------------- | ---------- | --------------------------- |
| Tracked brands                      | 1          | 1                           |
| Tracked competitors                 | 3          | Unlimited                   |
| Monitored prompts                   | 10         | 100                         |
| Engines                             | Perplexity | ChatGPT, Perplexity, Gemini |
| Competitor ranking & share of voice | —          | Yes                         |
| Refresh                             | Weekly     | Weekly                      |
| Runs per month                      | 50         | 1,500                       |

`runsPerMonth` is derived, not chosen: `prompts × engines × 5`, because a calendar month holds at most five weekly slots. Pro's unlimited competitors is a packaging promise, so `trackedCompetitors` is deliberately `null` — genuinely unlimited, not unset. Auto-discovered brands stay `suggested` and never count against it; the only ceiling is `config.limits.maxTrackedBrandsPerWorkspace`, a stability guard on the discovery write path rather than a pricing lever.

The last row is a **capability, not a quantity**, and it is enforced like one: `config.limits.<plan>.competitorRanking` is a boolean, read through `includesCompetitorRanking` in [`lib/runner/limits.ts`](../lib/runner/limits.ts) like every other limit. Free still tracks 3 competitors, and those brands still constrain the runner, discovery and the sources view — what free does not get is the ranking and share-of-voice view *built* from them. The two limits are deliberately independent; a change that made one follow the other would take tracking away from every free workspace.

The gate is applied **where the data is read**, not where it is drawn. [`lib/analytics/source-supabase.ts`](../lib/analytics/source-supabase.ts) resolves the workspace owner's plan before it issues the brand reads, and a plan without the view never selects the competitor brands or their `brand_daily_metrics` rows at all; `buildDashboardAnalytics` then has nothing to compute the four ranking surfaces from and returns `{ entitled: false, requiredPlan: "pro" }` in their place. That costs an extra round trip — entitlement decides what may be read, so it cannot be resolved in parallel with the reads it gates — and it is the point: hiding `CompetitorTable` while the query still ran would not be a gate. Only the reads the gate narrows pay for it; an entitled workspace issues the unfiltered `brand_daily_metrics` read alongside `brands`. Share of voice travels with the ranking rather than with visibility because its denominator *is* the tracked competitor set.

**Entitlement is the owner's, and a caller who is not the owner raises rather than being gated.** `subscriptions` is readable only by its own user (`auth.uid() = user_id`), so a non-owner reader sees no row — which is indistinguishable, from the row alone, from a workspace that genuinely has no paid plan. The two are told apart by the caller's own id (`client.auth.getClaims()`) against `workspaces.owner_user_id`: the owner with no row is a free workspace and resolves to `free` exactly as before, while a caller who is not the owner throws. Today `workspace_members` holds one owner row per workspace, so that path is unreachable and the raise costs nothing; `workspace_members` exists so that seats can be added by INSERT, and the first extra seat on a Pro workspace would otherwise be shown the plan gate silently — a paying customer's colleague told they are on the free plan, with nothing in the logs. Serving seats needs an owner-scoped plan lookup this read layer does not have (a `security definer` function shipped with its `revoke`, or the plan denormalised onto `workspaces`), and the raise is what forces that decision instead of shipping the wrong answer.

The gated state is its own screen (`CompetitorRankingGate` in [`components/dashboard/plan-gate.tsx`](../components/dashboard/plan-gate.tsx)), never the empty state. "Nothing has been measured yet" and "your plan does not include this view" have different causes and different remedies, and this product's whole contract is that absence renders its specific cause — so the gate names the plan, its price, and says plainly that the tracked competitors keep working. `data-slot="plan-gated"` distinguishes it from the empty states' `data-slot="no-value"` in tests. `REKKAL_DASHBOARD_FIXTURE=1` with `?scenario=gated` renders it against a fully populated workspace, which is the state that proves the gate does not read as missing data.

The shape trades freshness for breadth: more prompts tighten the aggregate visibility figure, while the weekly cadence keeps the run count — and the bill — bounded. Gemini's Google Search grounding is the most expensive of the three engines per query and is the one to watch when the run count changes.

How the runner enforces all of this — over-limit prompts skipped rather than deleted, and the limit snapshotted into `usage_periods.run_limit` — is in [analytics-pipeline.md](./analytics-pipeline.md).

## Competitor ranking

The headline claim is that your visibility is measured against the competitors who actually outrank you. That has a correctness requirement behind it: a ranking that silently omits the brands out-mentioning the customer is wrong, not merely incomplete. Any brand detected above the customer must be promoted into the ranking automatically, or flagged, rather than left off because nobody named it.
