Every campaign with at least one impression gets a 0–100 score: a weighted sum of seven dimensions, with weights that shift by campaign objective. The score maps to a letter grade so you can triage at a glance, and the dimensions tell you what to fix.
The grade scale
Scores are bucketed into five fixed bands. The thresholds are static — they are not calibrated against other accounts or a platform-wide median.
Campaigns with zero impressions in the analysis window are shown as “insufficient data” instead of a grade. Below roughly 1,000 impressions, ratios like CTR are statistically noisy — treat young campaigns’ grades as indicative, not final.
Seven dimensions
The composite score is the weighted sum of these seven dimensions, each scored 0–100 against the benchmark table for the campaign’s platform and objective. When a dimension’s input isn’t available (no previous period for trend, no reach data for saturation), it is excluded and the remaining weights are renormalized — a missing input never scores a disguised neutral. A former eighth dimension (creative health) was removed: its data was never fed, so it silently pulled every score toward a neutral default.
Click-through rate against the benchmark table for the campaign's platform and objective (e.g. 'good' is ~1.4% on Meta feed but ~3.5% on Google Search). Banded: ≥excellent scores 100, ≥good 72, ≥average 48, ≥poor 20.
Cost per click against the platform×objective benchmark (e.g. 'good' is ~€0.90 on Meta, ~€4.50 on Google Search). Banded: ≤excellent scores 100, ≤good 75, ≤average 50, ≤poor 20.
Cost per 1,000 impressions against the platform×objective benchmark. Banded: ≤excellent scores 100, ≤good 75, ≤average 50, ≤poor 25.
Utilization of the daily budget, normalized by the length of the analysis window: 80–105% of the intended spend scores 100; over- and under-spend score lower. A user-set budget target (campaign targets) takes precedence over the platform budget.
Conversion rate per click: ≥5% scores 100, ≥2% scores 70, ≥1% scores 40. Zero conversions scores 0. Counted conversions are purchases or leads as reported by each platform. Conversion VALUE (revenue) is ingested where the platform reports it (Meta, Google, Pinterest), enabling a real ROAS in the campaign list and narration — but the score itself stays count-based, not revenue-weighted.
CTR/CPC/CPM momentum vs the window of equal length immediately before the analysis window (±5% bands). The campaign analysis fetches that previous window explicitly. When no previous-period data exists, the dimension is EXCLUDED and the remaining weights are renormalized — it never scores a hidden neutral.
Ad frequency: ≤1.5 scores 100, ≤2.5 scores 75, ≤3.5 scores 50, above scores 25. Frequency is derived as impressions ÷ reach when the platform reports reach. Without reach data the dimension is EXCLUDED (weights renormalized) — never a fake 75.
Weight shifts by objective
A brand-awareness campaign shouldn’t be penalised for weak conversions the same way a conversion campaign would. Five weight profiles cover the objective families; campaigns whose objective doesn’t match any profile default to the traffic profile.
| Objective | CTR | CPC | CPM | Budget | Conv | Trend | Satur |
|---|---|---|---|---|---|---|---|
| Traffic | 30% | 30% | 10% | 10% | 5% | 15% | 0% |
| Conversions | 15% | 20% | 10% | 10% | 35% | 10% | 0% |
| Awareness | 10% | 5% | 35% | 15% | 0% | 15% | 20% |
| Leads | 20% | 15% | 10% | 10% | 30% | 15% | 0% |
| Engagement | 30% | 10% | 15% | 15% | 5% | 15% | 10% |
When scores compute
Scores are computed on demand, every time a page that shows them loads — there is no background recompute schedule and no manual “recompute” button. The default analysis window is the last 30 days (some surfaces use 7 days); the budget dimension normalizes spend by the window length so a 30-day window isn’t judged against a single day’s budget.
Data sources & honest limits
Grades pull exclusively from official platform reporting APIs (Meta Graph, Google Ads, TikTok, Snapchat, Pinterest, Microsoft, LinkedIn — depending on which accounts you connect). We never scrape ad-account UIs, and we never share your data with third parties. OAuth tokens are encrypted at rest (AES-256-GCM).
- The CTR/CPC/CPM benchmark bands start from static per-platform×objective tables, banded from public 2024–25 industry reports (good ≈ published median). Once an account has roughly 14 days of synced history, those bands are re-anchored to the account’s own trailing-90-day median (from the History page): the center blends toward the account median — capped so the industry table always keeps a vote, ramping up with more history — while the band shape (excellent:good:average:poor ratios) stays the industry one. With thin history the static table is used unchanged, so two accounts on the same platform×objective can be graded against different bars.
- Conversion value(revenue) is ingested only where the platform reports it (Meta’s
action_values, Google’sconversions_value, Pinterest’sTOTAL_CHECKOUT_VALUE_IN_MICRO_DOLLAR). A real ROAS (value ÷ spend) is then shown — in the campaign-list ROAS sort, the LLM narration, and the autopilotroas_floorguardrail —onlywhere that value is > 0. It is never a proxy: platforms that don’t report value show “—” and fall back to cost per conversion (CPA). ROAS does not enter the 0–100 score itself. - “Conversions” are what each platform reports as purchases or leads; attribution models differ per platform and are not normalized.
Retention.Insights are read on demand from the platforms’ APIs. Synced history (the History page) is kept until you disconnect the source account.
Formula
The composite score is a weighted sum over the available dimension scores, renormalized by the total weight of the dimensions that actually had data. Below is the canonical formula with the conversion-objective weights:
score = (0.15 * ctr + 0.20 * cpc + 0.10 * cpm
+ 0.10 * budget + 0.35 * conversions
+ 0.10 * trend + 0.00 * saturation) / Σ(available weights)
each dimension ∈ [0, 100], scored against the platform×objective benchmarks;
dimensions with no input data are excluded and the sum renormalized
grade = A if score ≥ 85
| B if score ≥ 70
| C if score ≥ 55
| D if score ≥ 35
| F otherwise
zero impressions in the window → "insufficient data", no gradeWeights live in src/lib/ai/scoring/weights.ts, thresholds in dimensions.ts, grade bands in categories.ts. Changing them requires a deploy.