SPB Git

spb/airiskindex Public

The most methodologically rigorous, fully transparent AI job-exposure index.

TypeScript 88% Python 6.1% SQL 2.7% CSS 1.2% JavaScript 0.9% Shell 0.8%

feat(web): distribution charts on detail pages + ultra-detailed README with metric badges

- DistributionChart: task histogram + position-in-index (emphasis form,
  recessive context bins, accent marker with rank pill)
- README: badges, headline metrics table, methodology summary, API docs,
  monorepo map, quick start, roadmap, citation & contact

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 5 days ago (Aug 5, 2026) parent c88bb20

Showing 3 changed files with +290 and −30

modified README.md +166 −16
@@ -1,29 +1,179 @@
1 # AI Risk Index — airiskindex.io
1 +<div align="center">
2 2
3 The most methodologically rigorous, fully transparent AI job-exposure index.
4 Task-based scoring of occupations on AI-driven automation exposure, with three
5 separate sub-scores per occupation — **exposure**, **substitution**, **augmentation**
6 each with confidence intervals, versioned methodology, and a public API.
3 +<img src="apps/web/public/logo.svg" alt="AI Risk Index" width="360" />
7 4
8 - Methodology (source of truth): [`docs/methodology/METHODOLOGY.md`](docs/methodology/METHODOLOGY.md)
9 - Research corpus grounding v1: [`docs/research/`](docs/research/README.md)
10 - Contributor rules: [`CLAUDE.md`](CLAUDE.md)
5 +# AI Risk Index
6 +
7 +**The most methodologically rigorous, fully transparent AI job-exposure index.**
8 +
9 +[![Live](https://img.shields.io/badge/live-www.airiskindex.io-2a78d6)](https://www.airiskindex.io)
10 +[![Methodology](https://img.shields.io/badge/methodology-1.0.0--draft.1-1c5cab)](https://www.airiskindex.io/methodology)
11 +[![Occupations](https://img.shields.io/badge/occupations-1%2C016-2a78d6)](https://www.airiskindex.io/occupations)
12 +[![Scored](https://img.shields.io/badge/scored-923-2a78d6)](https://www.airiskindex.io/ranking)
13 +[![Tasks rated](https://img.shields.io/badge/O*NET_tasks_rated-18%2C796-2a78d6)](https://www.airiskindex.io/methodology)
14 +[![Ratings](https://img.shields.io/badge/dimension_ratings-~225k-1c5cab)](https://www.airiskindex.io/methodology)
15 +[![Rater panel](https://img.shields.io/badge/rater_panel-Sonnet_5_%2B_Haiku_4.5-6da7ec)](https://www.airiskindex.io/methodology)
16 +[![Scores license](https://img.shields.io/badge/scores-CC_BY_4.0-0ca30c)](https://creativecommons.org/licenses/by/4.0/)
17 +
18 +[![Next.js](https://img.shields.io/badge/Next.js-14-000000?logo=nextdotjs)](apps/web)
19 +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](packages/scoring)
20 +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169e1?logo=postgresql&logoColor=white)](packages/db)
21 +[![Python](https://img.shields.io/badge/Python-3.12%2B-3776ab?logo=python&logoColor=white)](apps/etl)
22 +[![Monorepo](https://img.shields.io/badge/pnpm-Turborepo-f69220?logo=pnpm&logoColor=white)](pnpm-workspace.yaml)
23 +[![Tests](https://img.shields.io/badge/tests-12_passing-0ca30c)](packages/scoring/src)
24 +
25 +**[Ranking](https://www.airiskindex.io/ranking)** · **[Insights](https://www.airiskindex.io/insights)** · **[Methodology](https://www.airiskindex.io/methodology)** · **[API](https://www.airiskindex.io/api/v1/methodology)**
26 +
27 +</div>
28 +
29 +---
30 +
31 +## Headline metrics (run 1.0.0-draft.1 · 2026-08-05)
32 +
33 +| Metric | Value | Pastille |
34 +|---|---|---|
35 +| Share of the U.S. wage bill under substitution pressure | **32%** (≈ $4.7T of $14.5T) | ![32%](https://img.shields.io/badge/wage_bill_under_pressure-32%25-e34948) |
36 +| Employment-weighted substitution score | **34** / 100 | ![34](https://img.shields.io/badge/emp--weighted_substitution-34-2a78d6) |
37 +| Employment-weighted augmentation score | **55** / 100 | ![55](https://img.shields.io/badge/emp--weighted_augmentation-55-0ca30c) |
38 +| Workers in high-substitution occupations (≥ 70) | **5.3M** (2.7%) | ![5.3M](https://img.shields.io/badge/workers_high_pressure-5.3M-eda100) |
39 +| Workers in high-augmentation occupations (≥ 70) | **47.8M** (24.6%) | ![47.8M](https://img.shields.io/badge/workers_high_augmentation-47.8M-0ca30c) |
40 +| Coverage | 887 occupations · 194.2M workers (BLS OEWS) | ![coverage](https://img.shields.io/badge/coverage-194.2M_workers-6da7ec) |
41 +
42 +> "Under pressure" means paid work overlapping with what AI can plausibly take over — **not a payroll forecast**. Realized effects to date are concentrated and cohort-specific; measured usage remains majority-augmentative. The index is built as **adaptation guidance, not doom**.
43 +
44 +## What this is
45 +
46 +AI Risk Index scores **every U.S. occupation** on its exposure to AI-driven automation using a **task-based methodology**: each of the ~18,800 O*NET task statements is rated individually by a **multi-model LLM panel**, and occupation scores are derived from importance-weighted task scores. Every number is **transparent, versioned, and reproducible**.
47 +
48 +### The three scores (never collapsed into one)
49 +
50 +| Score | Question it answers |
51 +|---|---|
52 +| **Exposure** | Is AI *technically capable* of performing the occupation's tasks? |
53 +| **Substitution** | Does AI *actually replace* the human — after cost, barriers, and adoption? *(headline)* |
54 +| **Augmentation** | Does AI *assist* the human, raising productivity without replacing them? |
55 +
56 +Every score ships with a **confidence interval derived from rater disagreement** — multi-model replication shows single-model exposure ratings can vary by an order of magnitude, so a single-model index is an artifact. Ours makes the disagreement visible.
57 +
58 +## Methodology in one screen
59 +
60 +**Dimensions & weights** (v1 — defined once in [`packages/scoring/src/weights.ts`](packages/scoring/src/weights.ts), served live at [`/api/v1/methodology`](https://www.airiskindex.io/api/v1/methodology)):
61 +
62 +| Dimension | Weight | Orientation |
63 +|---|---|---|
64 +| Task automatability | 0.35 | direct |
65 +| Current technical feasibility | 0.20 | direct |
66 +| Cost of substitution vs. wage | 0.15 | direct |
67 +| Adoption barriers | 0.20 | **inverted** — strong barriers protect |
68 +| Sector adoption velocity | 0.10 | direct |
69 +
70 +**Formulas** — a rating *r* ∈ [1,5] normalizes to pressure *p* = (r−1)/4 (inverted: 1−(r−1)/4):
71 +
72 +```
73 +substitution_task = 100 · Σ_d w_d · p_d
74 +exposure_task = 100 · (w_auto·p_auto + w_feas·p_feas) / (w_auto + w_feas)
75 +augmentation_task = 100 · p_augmentation (rated separately, outside the composite)
76 +occupation = importance-weighted mean of its tasks (O*NET IM weights)
77 +CI bounds = worst/best-case envelope over the rater panel (min/mean/max)
78 +```
79 +
80 +The scoring engine ([`packages/scoring`](packages/scoring)) is **pure and deterministic** — no I/O, no clock, no randomness — pinned by property-based tests (fast-check) and a published worked example reproduced to 3 decimals. Full spec: [METHODOLOGY.md](docs/methodology/METHODOLOGY.md) · [changelog](docs/methodology/CHANGELOG.md).
81 +
82 +**Rating pipeline** ([`apps/worker`](apps/worker)): Anthropic **Message Batches** (50% discount, prompt-cached rubric, schema-constrained JSON output), one request per task × model, deterministic `custom_id`s → idempotent and resumable. Every rating stores model, prompt version, raw response, parsed score, and rationale — the **full audit trail is visible on every occupation page** (expand any task).
83 +
84 +**Integrity rules**: weights/formulas/prompt changes bump `INDEX_VERSION` with a changelog entry; every published score traces to an immutable `score_runs` row; historical runs stay queryable forever.
85 +
86 +## Data sources
87 +
88 +| Source | Version | Role | License |
89 +|---|---|---|---|
90 +| [O*NET database](https://www.onetcenter.org/database.html) | 30.3 (May 2026) | 1,016 occupations, 18,796 task statements, importance weights | CC BY 4.0 |
91 +| [BLS OEWS](https://www.bls.gov/oes/) | May 2025 national | Median wages + employment (830 SOCs) | Public domain |
92 +| ESCO v1.2 + ROME 4.0 | planned | EU/France crosswalk | EUPL / Licence Ouverte |
93 +| Adoption evidence (BTOS, Anthropic Economic Index, …) | 2025–2026 | Grounds the adoption-velocity & barriers rubrics | various |
94 +
95 +Raw dumps are immutable (`data/raw/`, fetched by script, never committed); derived artifacts commit **manifests only** (hashes + row counts). The research corpus grounding v1 — 20+ indices reviewed, verified source URLs, API cost analysis — lives in [`docs/research/`](docs/research/README.md).
96 +
97 +## Public API
98 +
99 +Versioned, JSON, `index_version` in every payload. Rate limit 60 req/min (600 with key).
100 +
101 +```bash
102 +# Health
103 +curl https://www.airiskindex.io/api/v1/health
104 +
105 +# Machine-readable methodology (weights, orientation, thresholds)
106 +curl https://www.airiskindex.io/api/v1/methodology
107 +
108 +# Search occupations
109 +curl "https://www.airiskindex.io/api/v1/occupations?q=paralegal"
110 +
111 +# Full score breakdown: sub-scores, CI bounds, tasks, wages
112 +curl https://www.airiskindex.io/api/v1/occupations/23-2011.00
113 +```
114 +
115 +## Monorepo
116 +
117 +```
118 +airiskindex/
119 +├── apps/
120 +│ ├── web/ # Next.js 14 — site + public API (ranking, insights, detail pages, drawer nav)
121 +│ ├── worker/ # BullMQ + Anthropic Message Batches rater pipeline, recompute, purge scripts
122 +│ └── etl/ # Python 3.12 — O*NET/OEWS download → transform (manifests) → Postgres load
123 +├── packages/
124 +│ ├── scoring/ # Pure TS scoring engine — the auditable core (weights, formulas, CI)
125 +│ ├── db/ # Prisma schema: occupations, tasks, rating audit trail, immutable runs
126 +│ ├── ui/ # Shared React components
127 +│ └── config/ # Shared tsconfig presets
128 +├── data/ # raw/ (immutable, gitignored) · derived/ (manifests committed)
129 +├── docs/
130 +│ ├── methodology/ # METHODOLOGY.md (source of truth), changelog, worked examples
131 +│ └── research/ # Research corpus: indices, data sources, rater API, market evidence
132 +└── infra/ # docker-compose, PM2 wrappers, ngrok config, deploy script
133 +```
11 134
12 135 ## Quick start
13 136
14 137 ```bash
15 138 pnpm install
16 docker compose -f infra/docker-compose.dev.yml up -d # postgres 16 + redis
17 cp infra/.env.example .env # fill values
139 +docker compose -f infra/docker-compose.dev.yml up -d # postgres 16 + redis (or brew services)
140 +cp infra/.env.example .env # fill DATABASE_URL, ANTHROPIC_API_KEY, RATER_MODELS…
18 141 pnpm db:migrate && pnpm db:seed
19 142 pnpm dev # web on :3000
20 143 ```
21 144
22 Checks: `pnpm typecheck && pnpm lint && pnpm test`
145 +Full pipeline to a real index:
146 +
147 +```bash
148 +cd apps/etl && make pipeline # O*NET 30.3 + OEWS → Postgres (1,016 occupations, 18,796 tasks)
149 +pnpm --filter @airiskindex/worker rate # multi-model batch rating (parallel, resumable)
150 +pnpm score:recompute # new immutable score run
151 +```
152 +
153 +Checks (must pass before any commit): `pnpm typecheck && pnpm lint && pnpm test`
154 +
155 +## Design system
156 +
157 +Charts follow a validated, colorblind-safe reference palette (sequential blue for magnitude, CI whiskers in muted ink, emphasis form for "you are here" distributions, visible data-table fallbacks). Fully responsive — right-side drawer navigation on mobile, dark mode with selected (not flipped) palette steps.
158 +
159 +## Roadmap
160 +
161 +- [ ] 5% human review sample + expert Delphi override queue (schema ready)
162 +- [ ] Sensitivity analyses published per release (`docs/methodology/sensitivity/`)
163 +- [ ] Convergent-validity report vs Felten AIOE, GPTs-are-GPTs, ILO gradients
164 +- [ ] ESCO/ROME crosswalk — EU & France coverage
165 +- [ ] API keys + rate limiting middleware
166 +- [ ] `INDEX_VERSION` 1.0.0 — first published run
167 +
168 +## License, citation & contact
169 +
170 +- **Scores & derived data**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — reuse freely with attribution *"AI Risk Index (airiskindex.io), version X"*.
171 +- **Code, site design & text**: © 2026 Simon-Pierre Boucher. All rights reserved.
172 +- Incorporates the O*NET® database (USDOL/ETA, CC BY 4.0) and BLS OEWS data. O*NET® is a trademark of USDOL/ETA, which has not reviewed or endorsed this project.
173 +
174 +> **Cite as:** *AI Risk Index, version 1.0.0-draft.1, airiskindex.io, Simon-Pierre Boucher (2026).*
23 175
24 ## Layout
176 +**Contact** — methodology questions, corrections, expert-panel participation, commercial licensing & contracts:
177 +**Simon-Pierre Boucher** · [contact@spboucher.ai](mailto:contact@spboucher.ai)
25 178
26 Monorepo (pnpm + Turborepo): `apps/web` (Next.js site + public API), `apps/worker`
27 (BullMQ jobs incl. the multi-model LLM rater), `apps/etl` (Python pipeline),
28 `packages/scoring` (pure, deterministic scoring engine), `packages/db` (Prisma),
29 `packages/ui`, `packages/config`. See CLAUDE.md §3.
179 +<sub>[Terms of Service](https://www.airiskindex.io/terms) · [Privacy Policy](https://www.airiskindex.io/privacy)</sub>
modified apps/web/app/occupations/[code]/page.tsx +60 −14
@@ -18,7 +18,12 @@ import {
18 18 pressure,
19 19 type DimensionKey,
20 20 } from "@airiskindex/scoring";
21 import { ScoreBar, ShareMeter, SubScoreDotPlot } from "@/components/score-marks";
21 +import {
22 + DistributionChart,
23 + ScoreBar,
24 + ShareMeter,
25 + SubScoreDotPlot,
26 +} from "@/components/score-marks";
22 27 import { formatWage, socGroupName } from "@/lib/soc-groups";
23 28
24 29 export const dynamic = "force-dynamic";
@@ -77,19 +82,35 @@ async function loadOccupation(code: string) {
77 82 rank += 1;
78 83 }
79 84
80 const related = score
81 ? await prisma.occupationScore.findMany({
82 where: {
83 runId: score.runId,
84 occupationCode: { startsWith: code.slice(0, 2), not: code },
85 },
86 orderBy: { substitution: "desc" },
87 take: 5,
88 include: { occupation: { select: { code: true, title: true } } },
89 })
90 : [];
85 + const [related, indexScores] = await Promise.all([
86 + score
87 + ? prisma.occupationScore.findMany({
88 + where: {
89 + runId: score.runId,
90 + occupationCode: { startsWith: code.slice(0, 2), not: code },
91 + },
92 + orderBy: { substitution: "desc" },
93 + take: 5,
94 + include: { occupation: { select: { code: true, title: true } } },
95 + })
96 + : Promise.resolve([]),
97 + score
98 + ? prisma.occupationScore.findMany({
99 + where: { runId: score.runId },
100 + select: { substitution: true },
101 + })
102 + : Promise.resolve([]),
103 + ]);
104 +
105 + return { occupation, score, taskScores, ratings, rank, scoredTotal, related, indexScores };
106 +}
91 107
92 return { occupation, score, taskScores, ratings, rank, scoredTotal, related };
108 +function toBins(values: number[], binCount = 20): number[] {
109 + const bins = Array.from({ length: binCount }, () => 0);
110 + for (const value of values) {
111 + bins[Math.min(binCount - 1, Math.floor((value / 100) * binCount))] += 1;
112 + }
113 + return bins;
93 114 }
94 115
95 116 function aggregateDimensions(
@@ -130,7 +151,7 @@ export default async function OccupationPage({
130 151 }): Promise<JSX.Element> {
131 152 const data = await loadOccupation(params.code);
132 153 if (!data) notFound();
133 const { occupation, score, taskScores, ratings, rank, scoredTotal, related } = data;
154 + const { occupation, score, taskScores, ratings, rank, scoredTotal, related, indexScores } = data;
134 155
135 156 const byTask = new Map(taskScores.map((entry) => [entry.taskId, entry]));
136 157 const ratingsByTask = new Map<string, typeof ratings>();
@@ -250,6 +271,31 @@ export default async function OccupationPage({
250 271 </p>
251 272 </div>
252 273 </div>
274 + <div className="mt-4 grid gap-4 lg:grid-cols-2">
275 + <div className="card min-w-0 p-5">
276 + <h3 className="text-sm font-semibold">Tasks on the substitution scale</h3>
277 + <p className="mt-0.5 text-xs text-[var(--muted)]">
278 + {taskScores.length} rated tasks, binned by substitution score.
279 + </p>
280 + <div className="mt-4">
281 + <DistributionChart bins={toBins(taskScores.map((t) => t.substitution))} />
282 + </div>
283 + </div>
284 + <div className="card min-w-0 p-5">
285 + <h3 className="text-sm font-semibold">Position among all scored occupations</h3>
286 + <p className="mt-0.5 text-xs text-[var(--muted)]">
287 + Distribution of {indexScores.length.toLocaleString("en-US")} occupation scores;
288 + the marker is this occupation.
289 + </p>
290 + <div className="mt-4">
291 + <DistributionChart
292 + bins={toBins(indexScores.map((s) => s.substitution))}
293 + marker={score.substitution}
294 + markerLabel={rank != null ? `#${rank} · ${score.substitution.toFixed(0)}` : score.substitution.toFixed(0)}
295 + />
296 + </div>
297 + </div>
298 + </div>
253 299 <div className="mt-4">
254 300 <ShareMeter
255 301 share={score.highlyExposedTaskShare}
modified apps/web/components/score-marks.tsx +64 −0
@@ -149,6 +149,70 @@ export function SubScoreDotPlot({
149 149 );
150 150 }
151 151
152 +/**
153 + * Compact histogram over the 0–100 scale (bins of equal width). Emphasis
154 + * form: recessive context columns with an optional accent marker line
155 + * ("this occupation"). Columns are thin with rounded caps, square baseline.
156 + */
157 +export function DistributionChart({
158 + bins,
159 + marker,
160 + markerLabel,
161 + height = 96,
162 + accent = false,
163 +}: {
164 + bins: number[];
165 + marker?: number;
166 + markerLabel?: string;
167 + height?: number;
168 + accent?: boolean;
169 +}): JSX.Element {
170 + const max = Math.max(1, ...bins);
171 + const binWidth = 100 / bins.length;
172 + return (
173 + <div>
174 + <div aria-hidden="true" className="relative" style={{ height }}>
175 + {/* baseline */}
176 + <div className="absolute inset-x-0 bottom-0 h-px bg-[var(--baseline)]" />
177 + {bins.map((count, index) => (
178 + <div
179 + key={index}
180 + className={`absolute bottom-0 rounded-t-[3px] ${accent && marker === undefined ? "bg-[var(--seq)]" : marker !== undefined ? "bg-[var(--seq-track)]" : "bg-[var(--seq)]"}`}
181 + style={{
182 + left: `calc(${index * binWidth}% + 1px)`,
183 + width: `calc(${binWidth}% - 2px)`,
184 + height: `${Math.max(count > 0 ? 3 : 0, (count / max) * 100)}%`,
185 + }}
186 + />
187 + ))}
188 + {marker !== undefined && (
189 + <>
190 + <div
191 + className="absolute inset-y-0 w-[2px] rounded bg-[var(--seq)]"
192 + style={{ left: pct(marker) }}
193 + />
194 + {markerLabel && (
195 + <span
196 + className="absolute -top-1 -translate-x-1/2 whitespace-nowrap rounded-full bg-[var(--seq)] px-2 py-0.5 text-[10px] font-semibold text-white"
197 + style={{ left: `clamp(3.5rem, ${pct(marker)}, calc(100% - 3.5rem))` }}
198 + >
199 + {markerLabel}
200 + </span>
201 + )}
202 + </>
203 + )}
204 + </div>
205 + <div aria-hidden="true" className="relative mt-1 h-4 text-[10px] text-[var(--muted)]">
206 + <span className="absolute left-0">0</span>
207 + <span className="absolute left-1/4 -translate-x-1/2">25</span>
208 + <span className="absolute left-1/2 -translate-x-1/2">50</span>
209 + <span className="absolute left-3/4 -translate-x-1/2">75</span>
210 + <span className="absolute right-0">100</span>
211 + </div>
212 + </div>
213 + );
214 +}
215 +
152 216 /** Meter: fill + same-ramp track (marks-and-anatomy §Figures). */
153 217 export function ShareMeter({ share, label }: { share: number; label: string }): JSX.Element {
154 218 const value = Math.round(share * 100);
155 219