# MODULE 08 / RANKING ENGINE
# Owner: solo build, AI-directed. Status: shipped.
# Depends on: 02 database, 03 categories, 05 reviews, 06 activity
## Goal
# One merit score per provider, per category and district,
# recomputed on a schedule. No paid placement. Ever.
## 1. Data model
create table provider_scores (
provider_id uuid references providers(id) on delete cascade,
category_id int references categories(id),
district_id int references districts(id),
score numeric(6,4) not null default 0,
components jsonb not null,
computed_at timestamptz not null default now(),
primary key (provider_id, category_id, district_id)
);
create index on provider_scores (category_id, district_id, score desc);
## 2. Weights and constants (single source of truth)
const W = { jobs: 0.30, rating: 0.25, repeat: 0.15,
recent: 0.15, convo: 0.10, profile: 0.05 };
const PRIOR = 3.9; // Bayesian prior mean rating
const K = 5; // prior strength, in review-equivalents
const HALF_LIFE = 180; // activity decay, in days
## 3. Scoring (TypeScript, pure, unit-tested)
export function score(p: Provider): Score {
const bayes = (p.reviews * p.rating + K * PRIOR)
/ (p.reviews + K);
const volume = Math.log10(p.jobs + 1) / Math.log10(MAX_JOBS);
const recent = Math.pow(0.5, p.daysInactive / HALF_LIFE);
const value = W.jobs * volume
+ W.rating * (bayes / 5)
+ W.repeat * clamp01(p.repeatRatio)
+ W.recent * recent
+ W.convo * clamp01(p.convoToJob)
+ W.profile * (p.isPaid ? p.completeness : 0);
return { value, components: { volume, bayes, recent } };
}
## 4. Scheduled recompute (every 6h, batched)
update provider_scores s set
score = compute(s.provider_id, s.category_id, s.district_id),
computed_at = now()
where s.computed_at < now() - interval '6 hours';
## 5. Verify before commit (see Review stage)
# - new providers not buried, paid tier cannot buy rank,
# - ordering identical on desktop and 390px mobile.