feat(provider-grades): Introduce ProviderGrades schema, rubric logic, and seed data with API integration in server.ts

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-17 07:47:50 -07:00
parent 52e214972e
commit c3c2331f4c
11 changed files with 789 additions and 0 deletions

View file

@ -27,6 +27,7 @@ import { macSyncStatusMigrations } from '@/entities/mac-sync-status';
import { contentPostMigrations } from '@/entities/content-post';
import { destinationMigrations } from '@/entities/destination';
import { destinationPerformanceMigrations } from '@/entities/destination-performance';
import { providerGradesMigrations } from '@/entities/provider-grades';
import { financialRecordMigrations } from '@/entities/financial-record';
import { flightMigrations } from '@/entities/flight';
import { hotelStayMigrations } from '@/entities/hotel-stay';
@ -157,6 +158,7 @@ await runMigrations(db, [
// Content / CMS entities
...destinationMigrations,
...destinationPerformanceMigrations,
...providerGradesMigrations,
...galleryItemMigrations,
...loreSectionMigrations,
...providerProfileMigrations,

View file

@ -30,4 +30,27 @@ export const destinationPerformanceMigrations: readonly Migration[] = [
await sql`CREATE INDEX IF NOT EXISTS idx_destination_performance_fit ON destination_performance(personal_fit_score DESC)`;
},
},
{
// Letter-grade overlay (per docs/grading-rubric.md). Captures Quinn's
// lived intel beyond the coarse personal_fit_score:
//
// observed_market_grade — what THIS market actually demanded when she
// visited. Feeds back into destinations.market_min_grades
// when it conflicts with the public score.
// effective_personal_grade — her effective grade in THIS market specifically;
// overrides provider_market_grades when present.
// Useful where a niche reputation locally outperforms
// the global grade (e.g. Cincinnati's trans scene
// where she's effectively A- vs B+ globally).
//
// Grades are validated app-side against rubric.ts:GRADES — no CHECK
// constraint to avoid drift from the source of truth.
id: '2026-05-17_destination_performance_grades',
async up(sql: Sql): Promise<void> {
await sql.unsafe(`ALTER TABLE destination_performance ADD COLUMN IF NOT EXISTS observed_market_grade TEXT`);
await sql.unsafe(`ALTER TABLE destination_performance ADD COLUMN IF NOT EXISTS effective_personal_grade TEXT`);
await sql.unsafe(`ALTER TABLE destination_performance ADD COLUMN IF NOT EXISTS provider_slug TEXT NOT NULL DEFAULT 'quinn'`);
await sql`CREATE INDEX IF NOT EXISTS idx_destination_performance_provider ON destination_performance(provider_slug)`;
},
},
];

View file

@ -0,0 +1,76 @@
{
"_meta": {
"description": "Quinn's lived experience overlay — markets she has a real read on. Seeded only where she has confirmed signal (Bay Area + Cincinnati + LA + Vegas). Other cities remain ungraded until visited.",
"provider": "quinn",
"updated": "2026-05-17"
},
"performance": [
{
"destinationSlug": "berkeley",
"providerSlug": "quinn",
"personalFitScore": 75,
"livedCompetitiveness": "Homebase. Established repeat-client base; mid-saturation Bay Area market. Premium clientele but plenty of elite cis competition for general-category bookings.",
"observedMarketGrade": "B",
"effectivePersonalGrade": "B",
"visitCount": 999,
"wouldReturn": true,
"notes": "Homebase — always 'in town' here."
},
{
"destinationSlug": "san-francisco",
"providerSlug": "quinn",
"personalFitScore": 70,
"livedCompetitiveness": "Strong daytime / tech-week presence; off-peak softer. Trans niche meaningfully different from general — opportunity sits in trans-positive client segments, not general luxury market.",
"observedMarketGrade": "B",
"effectivePersonalGrade": "B+",
"visitCount": 50,
"wouldReturn": true,
"notes": "Tech-week weeks and Pride run hot; mid-summer / late August slower."
},
{
"destinationSlug": "palo-alto",
"providerSlug": "quinn",
"personalFitScore": 72,
"livedCompetitiveness": "Peninsula tech wealth — clients quieter / more discrete than SF proper. Lower platform-saturation than SF. Repeat-driven.",
"observedMarketGrade": "B-",
"effectivePersonalGrade": "B",
"visitCount": 20,
"wouldReturn": true,
"notes": "Hotel-incall friendly. Best for repeats; cold prospecting harder than SF."
},
{
"destinationSlug": "cincinnati",
"providerSlug": "quinn",
"personalFitScore": 92,
"livedCompetitiveness": "Crushed it. Big fish in small pond — trans niche locally is wide open and the wealth tier is real (Midwest old money + Procter & Gamble executive base). Repeat clients booked within 3 days. Best dollar-per-day market on her tour history.",
"observedMarketGrade": "C-",
"effectivePersonalGrade": "A-",
"visitCount": 4,
"wouldReturn": true,
"lastRevenuePerDayUsd": 4200,
"notes": "Canonical big-fish-small-pond example. Effective grade A- because she over-qualifies; the market demands C-. Trans niche essentially uncontested."
},
{
"destinationSlug": "los-angeles",
"providerSlug": "quinn",
"personalFitScore": 25,
"livedCompetitiveness": "Crowded out. Too many elite cis providers competing for the same luxury-tier clients. Even with trans-niche advantage, the volume of high-grade providers in adjacent niches drowns out signal. Pricing pressure is real — clients comparison-shop aggressively.",
"observedMarketGrade": "A-",
"effectivePersonalGrade": "B-",
"visitCount": 6,
"wouldReturn": false,
"notes": "Not worth flying to. Market demands A-, she's effective B- here — under the bar. Skip unless paired with high-value events that justify the loss."
},
{
"destinationSlug": "las-vegas",
"providerSlug": "quinn",
"personalFitScore": 25,
"livedCompetitiveness": "Same crowded-out dynamic as LA. Convention-driven; high-grade providers fly in for every major event. Trans niche slightly less saturated than LA but still A-tier difficulty.",
"observedMarketGrade": "A-",
"effectivePersonalGrade": "B",
"visitCount": 5,
"wouldReturn": false,
"notes": "Same under-the-bar dynamic. Skip unless event-driven with a guaranteed booking."
}
]
}

View file

@ -120,4 +120,21 @@ export const destinationMigrations: readonly Migration[] = [
`);
},
},
{
// Per-category market difficulty grade (letter grade, A+..F). Empty {} means
// ungraded. Categories: 'general', 'trans', 'bbw', 'mature', 'gfe', etc.
// Validated app-side against PROVIDER_CATEGORIES / GRADES in
// @/entities/provider-grades/rubric. Empty fallback uses 'general'.
//
// Example: Vegas → {"general": "A-", "trans": "B"} — high overall bar but
// proportionally easier in the trans niche. See docs/grading-rubric.md for
// the canonical reference and seeding guidance.
id: '2026-05-17_destination_market_difficulty',
async up(sql: Sql): Promise<void> {
await sql.unsafe(`ALTER TABLE destinations ADD COLUMN IF NOT EXISTS market_min_grades JSONB NOT NULL DEFAULT '{}'::jsonb`);
// GIN index on the JSONB lets us filter by category presence efficiently
// (e.g. "all destinations with a trans-specific grade").
await sql`CREATE INDEX IF NOT EXISTS idx_destinations_market_min_grades ON destinations USING GIN (market_min_grades)`;
},
},
];

View file

@ -0,0 +1,26 @@
export { providerGradesMigrations } from './schema';
export {
listProviderMarketGrades,
getProviderMarketGrade,
upsertProviderMarketGrade,
updateProviderMarketGrade,
deleteProviderMarketGrade,
} from './repo';
export type {
ProviderMarketGrade,
ProviderMarketGradeDraft,
ProviderMarketGradePatch,
} from './types';
export {
GRADES,
PROVIDER_RUBRIC,
PROVIDER_CATEGORIES,
isGrade,
isProviderCategory,
gradeIndex,
gradeMeets,
gradeStepDelta,
resolveProviderGrade,
resolveMarketGrade,
} from './rubric';
export type { Grade, ProviderCategory, RubricTier } from './rubric';

View file

@ -0,0 +1,133 @@
import type { Sql } from '@/shared/db';
import { HttpError, badRequest, notFound } from '@/shared/http/errors';
import { isGrade, type Grade } from './rubric';
import type {
ProviderMarketGrade,
ProviderMarketGradeDraft,
ProviderMarketGradePatch,
} from './types';
interface Row {
provider_slug: string;
category: string;
grade: string;
rubric_notes: string | null;
updated_at: string;
}
const hydrate = (r: Row): ProviderMarketGrade => {
if (!isGrade(r.grade)) {
throw new Error(`provider_market_grades row has invalid grade "${r.grade}" for ${r.provider_slug}/${r.category}`);
}
return {
providerSlug: r.provider_slug,
category: r.category,
grade: r.grade,
rubricNotes: r.rubric_notes,
updatedAt: r.updated_at,
};
};
const validateGrade = (g: string): Grade => {
if (!isGrade(g)) throw badRequest('invalid_grade', `grade "${g}" is not in GRADES`);
return g;
};
export async function listProviderMarketGrades(
sql: Sql,
providerSlug?: string,
): Promise<readonly ProviderMarketGrade[]> {
try {
const rows = providerSlug
? await sql<Row[]>`SELECT * FROM provider_market_grades WHERE provider_slug = ${providerSlug} ORDER BY category`
: await sql<Row[]>`SELECT * FROM provider_market_grades ORDER BY provider_slug, category`;
return rows.map(hydrate);
} catch (err) {
throw new Error(`listProviderMarketGrades failed: ${String(err)}`);
}
}
export async function getProviderMarketGrade(
sql: Sql,
providerSlug: string,
category: string,
): Promise<ProviderMarketGrade | null> {
try {
const rows = await sql<Row[]>`
SELECT * FROM provider_market_grades
WHERE provider_slug = ${providerSlug} AND category = ${category}
`;
return rows[0] ? hydrate(rows[0]) : null;
} catch (err) {
throw new Error(`getProviderMarketGrade(${providerSlug}, ${category}) failed: ${String(err)}`);
}
}
export async function upsertProviderMarketGrade(
sql: Sql,
draft: ProviderMarketGradeDraft,
): Promise<ProviderMarketGrade> {
try {
const grade = validateGrade(draft.grade);
const rows = await sql<Row[]>`
INSERT INTO provider_market_grades (provider_slug, category, grade, rubric_notes, updated_at)
VALUES (${draft.providerSlug}, ${draft.category}, ${grade}, ${draft.rubricNotes ?? null}, now())
ON CONFLICT (provider_slug, category) DO UPDATE SET
grade = EXCLUDED.grade,
rubric_notes = EXCLUDED.rubric_notes,
updated_at = now()
RETURNING *
`;
if (!rows[0]) throw new Error('upsert returned no row');
return hydrate(rows[0]);
} catch (err) {
if (err instanceof HttpError) throw err;
throw new Error(`upsertProviderMarketGrade failed: ${String(err)}`);
}
}
export async function updateProviderMarketGrade(
sql: Sql,
providerSlug: string,
category: string,
patch: ProviderMarketGradePatch,
): Promise<ProviderMarketGrade> {
try {
const grade = patch.grade !== undefined ? validateGrade(patch.grade) : null;
const rows = await sql<Row[]>`
UPDATE provider_market_grades SET
grade = COALESCE(${grade}, grade),
rubric_notes = COALESCE(${patch.rubricNotes ?? null}, rubric_notes),
updated_at = now()
WHERE provider_slug = ${providerSlug} AND category = ${category}
RETURNING *
`;
if (!rows[0]) {
throw notFound('provider_market_grade_not_found', `${providerSlug}/${category} not found`);
}
return hydrate(rows[0]);
} catch (err) {
if (err instanceof HttpError) throw err;
throw new Error(`updateProviderMarketGrade failed: ${String(err)}`);
}
}
export async function deleteProviderMarketGrade(
sql: Sql,
providerSlug: string,
category: string,
): Promise<void> {
try {
const result = await sql`
DELETE FROM provider_market_grades
WHERE provider_slug = ${providerSlug} AND category = ${category}
`;
if (result.count === 0) {
throw notFound('provider_market_grade_not_found', `${providerSlug}/${category} not found`);
}
} catch (err) {
if (err instanceof HttpError) throw err;
throw new Error(`deleteProviderMarketGrade failed: ${String(err)}`);
}
}

View file

@ -0,0 +1,255 @@
/**
* Provider × market grading rubric (runtime mirror of docs/grading-rubric.md).
*
* If you change criteria, update docs/grading-rubric.md to match the AI cites
* both, so they must agree.
*/
export const GRADES = [
'F',
'D-',
'D',
'D+',
'C-',
'C',
'C+',
'B-',
'B',
'B+',
'A-',
'A',
'A+',
'S',
] as const;
export type Grade = (typeof GRADES)[number];
const GRADE_INDEX: ReadonlyMap<Grade, number> = new Map(GRADES.map((g, i) => [g, i]));
export function isGrade(value: unknown): value is Grade {
return typeof value === 'string' && GRADE_INDEX.has(value as Grade);
}
export function gradeIndex(g: Grade): number {
const i = GRADE_INDEX.get(g);
if (i === undefined) throw new Error(`unknown grade: ${g}`);
return i;
}
/**
* Strict clearance provider grade is >= market difficulty grade.
*
* Use for the boolean "can this provider work this market" gate.
*/
export function gradeMeets(provider: Grade, market: Grade): boolean {
return gradeIndex(provider) >= gradeIndex(market);
}
/**
* Step delta positive = provider over-qualifies; negative = under-qualifies.
*
* Use for big-fish-small-pond ranking: gap >= 2 steps signals a market where
* the provider stands out, not just clears the bar.
*/
export function gradeStepDelta(provider: Grade, market: Grade): number {
return gradeIndex(provider) - gradeIndex(market);
}
/**
* Standard provider categories. `general` is the default fallback when a
* market_min_grades JSONB doesn't specify the category.
*/
export const PROVIDER_CATEGORIES = [
'overall',
'general',
'trans',
'bbw',
'mature',
'gfe',
] as const;
export type ProviderCategory = (typeof PROVIDER_CATEGORIES)[number];
export function isProviderCategory(value: unknown): value is ProviderCategory {
return typeof value === 'string' && PROVIDER_CATEGORIES.includes(value as ProviderCategory);
}
export interface RubricTier {
readonly tier: string;
readonly criteria: readonly string[];
readonly notes?: string;
}
/**
* Per-grade criteria summary. Full prose lives in docs/grading-rubric.md.
* Keep this concise it's loaded into AI context per query.
*/
export const PROVIDER_RUBRIC: Readonly<Record<Grade, RubricTier>> = {
S: {
tier: 'celebrity',
criteria: [
'household name within niche',
'pricing ≥ $5k/hr without resistance',
'multi-market tour sells out months ahead',
'editorial-tier photography portfolio',
'press / brand presence beyond industry',
],
},
'A+': {
tier: 'elite',
criteria: [
'50+ verified reviews across markets',
'pricing $2.5k$5k/hr commanded routinely',
'P411 Trust / Tryst Diamond / Eros premium presence',
'sustained quarterly multi-market tours',
'≥40% repeat-client share',
],
notes: 'A+ in routine markets is rare — typically only in event-peak conditions.',
},
A: {
tier: 'elite',
criteria: [
'50+ verified reviews across markets',
'pricing $2.5k$5k/hr commanded routinely',
'P411 Trust / Tryst Diamond / Eros premium presence',
'sustained quarterly multi-market tours',
'≥40% repeat-client share',
],
},
'A-': {
tier: 'elite',
criteria: [
'50+ verified reviews across markets',
'pricing $2.5k$5k/hr commanded routinely',
'P411 Trust / Tryst Diamond / Eros premium presence',
'sustained quarterly multi-market tours',
'≥40% repeat-client share',
],
},
'B+': {
tier: 'strong-pro',
criteria: [
'1550 verified reviews',
'pricing $1k$2k/hr sustainable',
'active across 23 platforms with polished profiles',
'~2540% repeat-client share',
'dominates a clear niche (trans / BBW / mature / etc.)',
],
notes: 'B+ specifically requires categorical niche distinctiveness — not just polish.',
},
B: {
tier: 'strong-pro',
criteria: [
'1550 verified reviews',
'pricing $1k$2k/hr sustainable in home market',
'active across 23 platforms',
'~2540% repeat-client share',
'polished photo portfolio with variety',
],
},
'B-': {
tier: 'strong-pro',
criteria: [
'1025 verified reviews',
'pricing $1k/hr sustainable',
'active on 2+ platforms with complete profiles',
'growing repeat-client base',
'photography polished but not signature-tier',
],
},
'C+': {
tier: 'working-pro',
criteria: [
'515 reviews',
'pricing $500$1k/hr sustainable',
'active on 12 platforms; profiles complete but not premium',
'mixed pipeline — some repeat clients but not majority',
'decent photos that could be elevated',
],
},
C: {
tier: 'working-pro',
criteria: [
'515 reviews',
'pricing $500$1k/hr',
'one primary platform with complete profile',
'building repeat base',
'photos exist but inconsistent in style / quality',
],
},
'C-': {
tier: 'working-pro',
criteria: [
'<10 reviews but established cadence',
'pricing $400$700/hr',
'one primary platform',
'low repeat rate',
'self-shot or mixed-quality photo set',
],
},
'D+': {
tier: 'entry',
criteria: [
'<5 reviews / new to market',
'pricing $200$500/hr',
'limited platform presence',
'amateur / inconsistent photos',
'portfolio-building stage',
],
},
D: {
tier: 'entry',
criteria: [
'<5 reviews / new',
'pricing $200$400/hr',
'one platform, partial profile',
'self-shot photos',
'works permissive / low-density markets',
],
},
'D-': {
tier: 'entry',
criteria: [
'no verified reviews',
'pricing $150$300/hr',
'minimal platform presence',
'amateur photos only',
'can book only in very permissive markets',
],
},
F: {
tier: 'not-market-ready',
criteria: [
'no reviews',
'no professional photos',
'no platform presence',
'cannot reliably book',
],
},
};
/**
* Resolve the relevant provider grade for a clearance check.
*
* Falls back: requested category `overall` null.
*/
export function resolveProviderGrade(
grades: ReadonlyMap<ProviderCategory | string, Grade>,
category: ProviderCategory | string,
): Grade | null {
return grades.get(category) ?? grades.get('overall') ?? null;
}
/**
* Resolve the relevant market difficulty grade.
*
* Falls back: requested category `general` null.
*/
export function resolveMarketGrade(
marketMinGrades: Readonly<Record<string, string>>,
category: ProviderCategory | string,
): Grade | null {
const direct = marketMinGrades[category];
if (isGrade(direct)) return direct;
const fallback = marketMinGrades['general'];
return isGrade(fallback) ? fallback : null;
}

View file

@ -0,0 +1,29 @@
import type { Migration, Sql } from '@/shared/db';
export const providerGradesMigrations: readonly Migration[] = [
{
id: '2026-05-17_provider_market_grades_initial',
async up(sql: Sql): Promise<void> {
// Per-provider, per-category letter grade. The clearance test against a
// destination's market_min_grades JSONB drives opportunity ranking.
//
// category is open-ended (TS-validated against PROVIDER_CATEGORIES) so we
// can introduce new niches (bbw, mature, gfe, ...) without DB migrations.
// 'overall' is the headline; 'general' / 'trans' / niche grades drive math.
//
// grade is validated app-side against rubric.ts:GRADES — a CHECK constraint
// here would duplicate the source of truth and drift.
await sql`
CREATE TABLE IF NOT EXISTS provider_market_grades (
provider_slug TEXT NOT NULL,
category TEXT NOT NULL,
grade TEXT NOT NULL,
rubric_notes TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (provider_slug, category)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_provider_market_grades_provider ON provider_market_grades(provider_slug)`;
},
},
];

View file

@ -0,0 +1,27 @@
{
"_meta": {
"description": "Quinn's per-category market grades. Drives clearance check against destinations.market_min_grades.",
"rubric": "docs/grading-rubric.md",
"updated": "2026-05-17"
},
"grades": [
{
"providerSlug": "quinn",
"category": "overall",
"grade": "B-",
"rubricNotes": "Headline grade. C+ floor, B- ceiling. Strong photos + copy; review volume capped because she rotates markets rather than building one TER stronghold."
},
{
"providerSlug": "quinn",
"category": "general",
"grade": "C+",
"rubricNotes": "Open category — competes against elite cis providers. Clears entry in mid-tier markets; crowded out in A-tier general markets."
},
{
"providerSlug": "quinn",
"category": "trans",
"grade": "B+",
"rubricNotes": "Niche-strong. Small competitive pool, distinctive look (perfect perky E-cups implants), verified, repeat client base. This is where opportunity actually lives."
}
]
}

View file

@ -0,0 +1,18 @@
import type { Grade, ProviderCategory } from './rubric';
export interface ProviderMarketGrade {
readonly providerSlug: string;
readonly category: ProviderCategory | string;
readonly grade: Grade;
readonly rubricNotes: string | null;
readonly updatedAt: string;
}
export interface ProviderMarketGradeDraft {
readonly providerSlug: string;
readonly category: ProviderCategory | string;
readonly grade: Grade;
readonly rubricNotes?: string | null;
}
export type ProviderMarketGradePatch = Partial<Omit<ProviderMarketGradeDraft, 'providerSlug' | 'category'>>;

183
docs/grading-rubric.md Normal file
View file

@ -0,0 +1,183 @@
# Provider × Market Grading Rubric
A reproducible system for grading both *providers* (escorts) and *markets* (cities)
on the same A-through-F scale, so the AI researcher can answer:
"Can this provider thrive in this market?"
The clearance test is simple: `provider_grade(category) >= market_difficulty(category)`.
If the provider clears the bar, the market is viable. The over-qualification gap
hints at how dominant they'll be (big fish in small pond).
This rubric is the canonical reference. The TypeScript const at
`codebase/@features/api/src/entities/provider-grades/rubric.ts` mirrors it for
runtime use. Update both together.
---
## Grade scale
```
F D- D D+ C- C C+ B- B B+ A- A A+ S
```
`F` = unranked / not market-ready. `S` = celebrity tier, top ~1%.
---
## Provider grade — what each letter requires
Each axis is scored qualitatively. A provider's grade is the **lowest-axis ceiling**
modified by standout strengths; one weak axis caps the overall grade unless
distinctively offset.
### Axes
- **Review volume** — verified reviews on TER / P411 / Eros / Tryst across markets.
- **Photo tier** — production quality, recency, professional vs. selfie mix, variety.
- **Copy quality** — bio, screening notes, ad copy that signals taste / scarcity.
- **Marketing presence** — Tryst/P411/Eros tier, profile completeness, advertising spend.
- **Pricing power** — what the market will pay at her rate without resistance.
- **Repeat-client density** — % of bookings from prior clients (signals retention).
- **Niche distinctiveness** — is she categorically rare in her primary niche?
### Tier definitions
#### S — Celebrity (top ~1%)
- Household name within niche; clients fly to *her*.
- Pricing > $$$$ (≥$5k/hr) without resistance.
- Multi-market tour selling out months in advance.
- Editorial-tier photography portfolio.
- Heavy press / brand presence beyond the industry.
#### A+ / A / A- — Elite (top 510%)
- 50+ verified reviews across multiple markets.
- Top-tier photography (professional, recent, signature look).
- Multi-market sustained presence — confirmed tours quarterly.
- Pricing $$$$ (~$2.5k$5k/hr) commanded without resistance.
- Strong repeat-client base (≥40% repeat).
- Marketing presence: P411 Trust, Tryst Diamond, Eros premium.
#### B+ / B / B- — Strong working pro (top 25%)
- 1550 verified reviews.
- Polished photos with variety (multiple sets, recent).
- Pricing $$$ (~$1k$2k/hr) sustainable in home market.
- Repeat-client base (~2540% repeat).
- Marketing presence active across 23 platforms.
- **B+ specifically:** dominates a clear niche (e.g., trans, BBW, mature) — categorically distinctive in a smaller pool.
#### C+ / C / C- — Solid working pro (top 50%)
- 515 reviews.
- Decent photos but not breakout (could be better lit / staged / shot).
- Pricing $$ (~$500$1k/hr) sustainable.
- Mixed booking pipeline; some repeat clients but not majority.
- Active on 12 platforms; profiles complete but not premium-tier.
#### D+ / D / D- — Entry tier
- <5 reviews or new to market.
- Photos exist but are amateur / inconsistent / self-shot.
- Pricing $ (~$200$500/hr).
- Limited platform presence; profile may be incomplete.
- Pre-portfolio building stage; can work permissive markets.
#### F — Not market-ready
- No reviews, no professional photos, no platform presence.
- Cannot reliably book even in entry markets.
---
## Market difficulty grade — what each letter means
The **minimum grade required** to thrive in a given market in a given category.
A market is harder when many high-grade providers compete for the same clients
(Vegas, LA, NYC) — entry providers get crowded out.
### Tier definitions
#### S markets — Only celebrity-tier providers thrive
- Monaco, Dubai during F1 week, Cannes during the festival.
- Below S = noise. Even A-tier providers struggle to differentiate.
#### A markets (A- / A / A+) — Saturated luxury markets
- **A-:** Las Vegas, Los Angeles (general), Miami, New York.
- **A:** Aspen / Vail in season, Hamptons in summer, San Francisco / Bay Area during major tech weeks.
- **A+:** None routine — only ephemeral peaks (Super Bowl host city, Art Basel Miami).
- Below A = crowded out by sheer provider density.
#### B markets (B- / B / B+) — Affluent but reachable
- **B-:** Amsterdam, Toronto, Chicago, Boston, DC, Seattle.
- **B:** Atlanta, Denver, Austin, Berkeley / SF (off-peak), San Diego.
- **B+:** Top European capitals during peak business travel (London, Paris).
- Strong-pro level (B-tier) thrives; mid-tier struggles unless niche.
#### C markets (C- / C / C+) — Mid-tier wealth, mid-tier competition
- **C+:** Cincinnati, Indianapolis, Kansas City, Pittsburgh, Charlotte, Nashville.
- **C:** Columbus, Louisville, Cleveland, Minneapolis, St. Louis, Tampa.
- **C-:** Smaller secondary cities with established but not flooded provider scenes.
- *Big-fish-small-pond opportunity zone for B-tier providers.*
#### D markets (D- / D / D+) — Permissive / emerging
- **D:** European general markets (most non-capital cities), South American capitals.
- **D-:** Cities with limited paid-companionship economy; entry providers can book.
- Pricing pressure is real but competition is sparse.
### Niche overrides
A market's difficulty is *per category*. Cities with thin niche scenes have lower
difficulty in that niche even if their `general` grade is high.
- **Las Vegas:** general A-, trans B (smaller trans-specific competitor pool).
- **Amsterdam:** general B-, trans B- (proportional).
- **Cincinnati:** general C+, trans C- (very few trans providers — niche wide open).
- **Berlin / Tel Aviv:** general D, trans C (relatively sophisticated trans-aware market).
Stored as JSONB on `destinations.market_min_grades`, e.g.:
```json
{"general": "A-", "trans": "B"}
```
Missing categories fall back to `general`. Empty `{}` means the market hasn't been
graded yet.
---
## Quinn's current grades (provider_market_grades seed)
| category | grade | notes |
|---|---|---|
| `overall` | B- | Mid-range overall — C+ floor, B- ceiling. Strong photos and copy; review volume capped because she rotates markets rather than building one TER stronghold. |
| `general` | C+ | Against the open category (cis + trans + all niches), she clears entry but elite cis providers dominate. |
| `trans` | B+ | Niche-strong — small competitive pool, distinctive look, verified-implant confirmed, repeat client base. Where her opportunity actually lives. |
The `overall` grade is the headline; `category` grades drive market-clearance math.
---
## How to update grades
- **Provider grades:** quarterly review based on the axes above. Bump when sustained
evidence accumulates (review count crossed threshold; pricing held a tier increase;
niche dominance solidified).
- **Market difficulty grades:** ad-hoc as new intel arrives. After a tour stop, the
`destination_performance.observed_market_grade` captures *what the market actually
demanded* — feed that back into `destinations.market_min_grades` if it conflicts
with the stored value.
- **Rubric itself:** if you change criteria here, update `rubric.ts` to match —
the AI cites both, so they must agree.
---
## How the AI uses this
`listOpportunityRanked` in `destination/repo.ts` joins:
1. `destinations.market_min_grades` (per-category market difficulty)
2. `provider_market_grades` (provider's grade per category)
3. `destination_performance.effective_personal_grade` (override when she has lived intel)
It returns each destination ranked by `clears_bar AND region_value DESC`. Cities
where Quinn over-qualifies by 2+ grade steps are flagged as `big_fish_small_pond`
opportunities.
The `/engine/opportunity-locations?category=trans` endpoint returns the trans-niche
view — markets where her B+ trans grade clears the bar that her C+ general grade
doesn't.