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%
1// File: page.tsx2// Path: apps/web/app/insights/page.tsx3// Project: AI Risk Index — airiskindex.io4// Author: Simon-Pierre Boucher5// Contact: contact@spboucher.ai6// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.7//8// Description: Insights — headline metrics: wage bill under pressure, workers in high-pressure occupations, augmentation counterpoint.910import Link from "next/link";11import { prisma } from "@airiskindex/db";12import { HIGH_EXPOSURE_THRESHOLD } from "@airiskindex/scoring";13import { DecileColumns, ScatterPlot } from "@/components/insights-charts";14import { DistributionChart } from "@/components/score-marks";15import { SOC_MAJOR_GROUPS } from "@/lib/soc-groups";1617export const dynamic = "force-dynamic";1819export const metadata = {20 title: "Insights — AI Risk Index",21 description:22 "Headline metrics from the index: share of the U.S. wage bill under AI substitution pressure, workers in high-pressure occupations, and the augmentation counterpoint.",23};2425interface Aggregates {26 n: number;27 total_emp: number;28 wage_bill: number;29 wage_at_risk: number;30 emp_weighted_sub: number;31 emp_weighted_aug: number;32 emp_high_sub: number;33 emp_high_aug: number;34 avg_ci_width: number;35}3637function money(value: number): string {38 if (value >= 1e12) return `$${(value / 1e12).toFixed(2)}T`;39 if (value >= 1e9) return `$${(value / 1e9).toFixed(0)}B`;40 return `$${Math.round(value).toLocaleString("en-US")}`;41}4243function millions(value: number): string {44 return value >= 1e6 ? `${(value / 1e6).toFixed(1)}M` : Math.round(value).toLocaleString("en-US");45}4647async function loadInsights() {48 const run = await prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } });49 if (!run) return null;5051 const [agg] = await prisma.$queryRaw<Aggregates[]>`52 SELECT count(*)::int AS n,53 sum(o.employment)::float8 AS total_emp,54 sum(o.employment::float8 * o."medianWageCents"::float8 / 100.0)::float8 AS wage_bill,55 sum(o.employment::float8 * o."medianWageCents"::float8 / 100.0 * s.substitution / 100.0)::float8 AS wage_at_risk,56 (sum(o.employment * s.substitution) / sum(o.employment))::float8 AS emp_weighted_sub,57 (sum(o.employment * s.augmentation) / sum(o.employment))::float8 AS emp_weighted_aug,58 sum(CASE WHEN s.substitution >= 70 THEN o.employment ELSE 0 END)::float8 AS emp_high_sub,59 sum(CASE WHEN s.augmentation >= 70 THEN o.employment ELSE 0 END)::float8 AS emp_high_aug,60 avg(s."substitutionHigh" - s."substitutionLow")::float8 AS avg_ci_width61 FROM "OccupationScore" s62 JOIN "Occupation" o ON o.code = s."occupationCode"63 WHERE s."runId" = ${run.id} AND o.employment IS NOT NULL AND o."medianWageCents" IS NOT NULL64 `;6566 const [taskShare] = await prisma.$queryRaw<Array<{ share: number; total: bigint }>>`67 SELECT (count(*) FILTER (WHERE substitution >= ${HIGH_EXPOSURE_THRESHOLD}))::float8 / count(*) AS share,68 count(*) AS total69 FROM "TaskScore" WHERE "runId" = ${run.id}70 `;7172 const [scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI] = await Promise.all([73 prisma.$queryRaw<Array<{ x: number; y: number; size: number | null; label: string }>>`74 SELECT s.substitution::float8 AS x, s.augmentation::float8 AS y,75 o.employment::float8 AS size, o.title AS label76 FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode"77 WHERE s."runId" = ${run.id}78 `,79 prisma.$queryRaw<Array<{ prefix: string; sub: number; n: number; emp: number }>>`80 SELECT left(s."occupationCode", 2) AS prefix,81 (sum(s.substitution * COALESCE(o.employment, 0)) / NULLIF(sum(COALESCE(o.employment, 0)), 0))::float8 AS sub,82 count(*)::int AS n,83 sum(COALESCE(o.employment, 0))::float8 AS emp84 FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode"85 WHERE s."runId" = ${run.id}86 GROUP BY 1 HAVING sum(COALESCE(o.employment, 0)) > 087 ORDER BY 2 DESC88 `,89 prisma.$queryRaw<Array<{ decile: number; sub: number; lo: number; hi: number }>>`90 WITH d AS (91 SELECT s.substitution, o.employment::float8 AS emp, o."medianWageCents",92 ntile(10) OVER (ORDER BY o."medianWageCents") AS decile93 FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode"94 WHERE s."runId" = ${run.id} AND o."medianWageCents" IS NOT NULL AND o.employment IS NOT NULL95 )96 SELECT decile::int, (sum(substitution * emp) / sum(emp))::float8 AS sub,97 min("medianWageCents")::float8 AS lo, max("medianWageCents")::float8 AS hi98 FROM d GROUP BY decile ORDER BY decile99 `,100 prisma.occupationScore.findMany({101 where: { runId: run.id },102 orderBy: { substitution: "desc" },103 take: 20,104 include: {105 occupation: { select: { code: true, title: true, medianWageCents: true, employment: true } },106 },107 }),108 prisma.occupationScore.findMany({109 where: { runId: run.id },110 select: { substitution: true },111 }),112 prisma.$queryRaw<Array<{ code: string; title: string; employment: number; sub: number }>>`113 SELECT o.code, o.title, o.employment::float8 AS employment, s.substitution::float8 AS sub114 FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode"115 WHERE s."runId" = ${run.id} AND s.substitution >= 60 AND o.employment IS NOT NULL116 ORDER BY o.employment DESC LIMIT 6117 `,118 prisma.$queryRaw<Array<{ code: string; title: string; width: number; sub: number }>>`119 SELECT o.code, o.title, (s."substitutionHigh" - s."substitutionLow")::float8 AS width,120 s.substitution::float8 AS sub121 FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode"122 WHERE s."runId" = ${run.id}123 ORDER BY 3 DESC LIMIT 6124 `,125 ]);126127 return { run, agg, taskShare, scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI };128}129130function toBins(values: number[], binCount = 20): number[] {131 const bins = Array.from({ length: binCount }, () => 0);132 for (const value of values) {133 bins[Math.min(binCount - 1, Math.floor((value / 100) * binCount))] += 1;134 }135 return bins;136}137138const wageShort = (cents: number): string => `$${Math.round(cents / 100_000)}k`;139140function Tile({141 label,142 value,143 detail,144}: {145 label: string;146 value: string;147 detail: string;148}): JSX.Element {149 return (150 <div className="card card-hover p-5">151 <p className="text-sm font-medium text-[var(--ink-2)]">{label}</p>152 <p className="mt-1 text-3xl font-semibold">{value}</p>153 <p className="mt-2 text-xs leading-relaxed text-[var(--muted)]">{detail}</p>154 </div>155 );156}157158export default async function InsightsPage(): Promise<JSX.Element> {159 const data = await loadInsights();160 if (!data) {161 return (162 <main className="mx-auto max-w-4xl px-4 py-14 sm:px-6">163 <h1 className="text-3xl font-bold tracking-tight">Insights</h1>164 <p className="mt-4 text-[var(--ink-2)]">No score run published yet.</p>165 </main>166 );167 }168 const { run, agg, taskShare, scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI } = data;169 const atRiskShare = (agg.wage_at_risk / agg.wage_bill) * 100;170171 return (172 <main className="hero-wash mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-14">173 <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--muted)]">174 Insights · run {run.indexVersion} · {run.createdAt.toISOString().slice(0, 10)}175 </p>176 <h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">177 What the index says, in aggregate178 </h1>179 <p className="mt-3 max-w-2xl text-[var(--ink-2)]">180 Employment- and wage-weighted headline metrics across {agg.n.toLocaleString("en-US")}{" "}181 scored occupations covering {millions(agg.total_emp)} U.S. workers (BLS OEWS wages and182 employment). Pressure, not prophecy — see how to read this below.183 </p>184185 {/* Hero figure */}186 <div className="card mt-8 p-6 sm:p-8">187 <p className="text-sm font-medium text-[var(--ink-2)]">188 Share of the U.S. wage bill under substitution pressure189 </p>190 <p className="mt-2 text-6xl font-bold tracking-tight sm:text-7xl">191 {atRiskShare.toFixed(0)}192 <span className="text-3xl font-semibold text-[var(--ink-2)]">%</span>193 </p>194 <p className="mt-2 text-sm text-[var(--muted)]">195 ≈ {money(agg.wage_at_risk)} of {money(agg.wage_bill)} in annual median wages,196 substitution-score-weighted across occupations. This measures how much paid work sits in197 exposed tasks — not wages that will disappear.198 </p>199 </div>200201 <div className="mt-4 grid grid-cols-2 gap-4 lg:grid-cols-3">202 <Tile203 label="Employment-weighted substitution"204 value={agg.emp_weighted_sub.toFixed(0)}205 detail="Average substitution score experienced by the typical U.S. worker (0–100)."206 />207 <Tile208 label="Employment-weighted augmentation"209 value={agg.emp_weighted_aug.toFixed(0)}210 detail="The counterpoint: how much AI assists the typical worker's tasks without replacing them."211 />212 <Tile213 label="Workers in high-pressure occupations"214 value={millions(agg.emp_high_sub)}215 detail={`Employed in occupations with substitution ≥ ${HIGH_EXPOSURE_THRESHOLD} — ${((agg.emp_high_sub / agg.total_emp) * 100).toFixed(1)}% of covered employment.`}216 />217 <Tile218 label="Workers in high-augmentation occupations"219 value={millions(agg.emp_high_aug)}220 detail={`Employed in occupations with augmentation ≥ ${HIGH_EXPOSURE_THRESHOLD} — ${((agg.emp_high_aug / agg.total_emp) * 100).toFixed(1)}% of covered employment.`}221 />222 <Tile223 label="Highly exposed tasks"224 value={`${(taskShare.share * 100).toFixed(1)}%`}225 detail={`Of ${Number(taskShare.total).toLocaleString("en-US")} rated O*NET tasks, the share scoring ≥ ${HIGH_EXPOSURE_THRESHOLD} on substitution.`}226 />227 <Tile228 label="Average confidence-interval width"229 value={`±${(agg.avg_ci_width / 2).toFixed(1)}`}230 detail="Mean half-width of the substitution CI from rater disagreement — the index's own uncertainty, stated plainly."231 />232 </div>233234 {/* The signature chart: substitution × augmentation */}235 <section className="mt-12">236 <h2 className="text-xl font-semibold tracking-tight">237 Substitution × augmentation — every occupation238 </h2>239 <p className="mt-1 max-w-2xl text-sm text-[var(--ink-2)]">240 The core claim of the methodology in one picture: replacement and assistance are241 different axes. Each dot is one of {scatter.length.toLocaleString("en-US")} occupations;242 dot area reflects U.S. employment. Hover a dot for its name.243 </p>244 <div className="card mt-4 p-4 sm:p-6">245 <ScatterPlot points={scatter} />246 </div>247 </section>248249 <section className="mt-10 grid gap-4 lg:grid-cols-2">250 <div className="card min-w-0 p-5 sm:p-6">251 <h2 className="text-lg font-semibold tracking-tight">The shape of the index</h2>252 <p className="mt-1 text-sm text-[var(--ink-2)]">253 Distribution of all {allScores.length.toLocaleString("en-US")} occupation substitution254 scores; the marker is the employment-weighted mean.255 </p>256 <div className="mt-4">257 <DistributionChart258 bins={toBins(allScores.map((s) => s.substitution))}259 marker={agg.emp_weighted_sub}260 markerLabel={`workers' mean · ${agg.emp_weighted_sub.toFixed(0)}`}261 />262 </div>263 </div>264 <div className="card min-w-0 p-5 sm:p-6">265 <h2 className="text-lg font-semibold tracking-tight">Pressure by wage decile</h2>266 <p className="mt-1 text-sm text-[var(--ink-2)]">267 Employment-weighted mean substitution per median-wage decile — where on the pay scale268 the pressure sits.269 </p>270 <div className="mt-4">271 <DecileColumns272 columns={deciles.map((d) => ({273 label: `${wageShort(d.lo)}–${wageShort(d.hi)}`,274 value: d.sub,275 }))}276 />277 </div>278 </div>279 </section>280281 <section className="mt-10">282 <h2 className="text-xl font-semibold tracking-tight">By occupation group</h2>283 <p className="mt-1 text-sm text-[var(--ink-2)]">284 Employment-weighted mean substitution per SOC major group.285 </p>286 <div className="card mt-4 overflow-hidden">287 {groups.map((group) => (288 <Link289 key={group.prefix}290 href={`/occupations#g${group.prefix}`}291 className="block border-b border-[var(--grid)] px-4 py-3 last:border-b-0 hover:bg-[var(--wash)] sm:px-5"292 >293 <div className="flex items-baseline gap-3">294 <span className="min-w-0 truncate text-sm">295 {SOC_MAJOR_GROUPS[group.prefix] ?? group.prefix}296 </span>297 <span className="ml-auto shrink-0 text-xs text-[var(--muted)]">298 {group.n} occ. · {millions(group.emp)} workers299 </span>300 <span className="w-8 shrink-0 text-right text-sm font-semibold tabular-nums">301 {group.sub.toFixed(0)}302 </span>303 </div>304 <div aria-hidden="true" className="mt-1.5 h-[6px] rounded-r-[3px] bg-[var(--seq-track)]">305 <div306 className="h-full rounded-r-[3px] bg-[var(--seq)]"307 style={{ width: `${Math.min(100, group.sub)}%` }}308 />309 </div>310 </Link>311 ))}312 </div>313 </section>314315 <section className="mt-10">316 <div className="flex flex-wrap items-baseline justify-between gap-2">317 <h2 className="text-xl font-semibold tracking-tight">Top 20 by substitution</h2>318 <Link href="/ranking" className="text-xs text-[var(--muted)] underline hover:text-[var(--ink)]">319 full index →320 </Link>321 </div>322 <div className="card mt-4 overflow-x-auto">323 <table className="w-full min-w-[640px] text-left text-sm">324 <caption className="sr-only">Top 20 occupations by substitution score</caption>325 <thead className="border-b border-[var(--grid)] text-xs uppercase tracking-wide text-[var(--muted)]">326 <tr>327 <th className="px-4 py-3 font-medium">#</th>328 <th className="px-4 py-3 font-medium">Occupation</th>329 <th className="px-4 py-3 font-medium">Substitution (CI)</th>330 <th className="px-4 py-3 font-medium">Exposure</th>331 <th className="px-4 py-3 font-medium">Augment.</th>332 <th className="px-4 py-3 font-medium">Median wage</th>333 <th className="px-4 py-3 font-medium">Employed</th>334 </tr>335 </thead>336 <tbody className="tabular-nums">337 {top20.map((row, index) => (338 <tr key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0 hover:bg-[var(--wash)]">339 <td className="px-4 py-2.5 text-[var(--muted)]">{index + 1}</td>340 <td className="max-w-xs px-4 py-2.5">341 <Link href={`/occupations/${row.occupationCode}`} className="hover:underline">342 {row.occupation.title}343 </Link>344 </td>345 <td className="px-4 py-2.5 font-semibold">346 {row.substitution.toFixed(1)}347 <span className="ml-1 font-normal text-[var(--muted)]">348 ({row.substitutionLow.toFixed(0)}–{row.substitutionHigh.toFixed(0)})349 </span>350 </td>351 <td className="px-4 py-2.5">{row.exposure.toFixed(0)}</td>352 <td className="px-4 py-2.5">{row.augmentation.toFixed(0)}</td>353 <td className="px-4 py-2.5">354 {row.occupation.medianWageCents != null355 ? `$${Math.round(row.occupation.medianWageCents / 100).toLocaleString("en-US")}`356 : "—"}357 </td>358 <td className="px-4 py-2.5">359 {row.occupation.employment != null360 ? row.occupation.employment.toLocaleString("en-US")361 : "—"}362 </td>363 </tr>364 ))}365 </tbody>366 </table>367 </div>368 </section>369370 <section className="mt-12 grid gap-6 lg:grid-cols-2">371 <div className="min-w-0">372 <h2 className="text-lg font-semibold tracking-tight">373 Most workers in high-pressure occupations374 </h2>375 <p className="mt-1 text-sm text-[var(--ink-2)]">376 Substitution ≥ 60, ordered by U.S. employment.377 </p>378 <ul className="card mt-3 overflow-hidden">379 {mostWorkersAtHigh.map((row) => (380 <li key={row.code} className="border-b border-[var(--grid)] last:border-b-0">381 <Link382 href={`/occupations/${row.code}`}383 className="flex items-baseline gap-3 px-4 py-3 hover:bg-[var(--wash)]"384 >385 <span className="min-w-0 truncate text-sm">{row.title}</span>386 <span className="ml-auto shrink-0 text-xs text-[var(--muted)]">387 {millions(row.employment)} workers388 </span>389 <span className="shrink-0 text-sm font-semibold tabular-nums">390 {row.sub.toFixed(0)}391 </span>392 </Link>393 </li>394 ))}395 </ul>396 </div>397 <div className="min-w-0">398 <h2 className="text-lg font-semibold tracking-tight">Where the panel disagrees most</h2>399 <p className="mt-1 text-sm text-[var(--ink-2)]">400 Widest confidence intervals — scores to hold loosely, and first in line for expert401 review.402 </p>403 <ul className="card mt-3 overflow-hidden">404 {widestCI.map((row) => (405 <li key={row.code} className="border-b border-[var(--grid)] last:border-b-0">406 <Link407 href={`/occupations/${row.code}`}408 className="flex items-baseline gap-3 px-4 py-3 hover:bg-[var(--wash)]"409 >410 <span className="min-w-0 truncate text-sm">{row.title}</span>411 <span className="ml-auto shrink-0 text-xs text-[var(--muted)]">412 ±{(row.width / 2).toFixed(0)}413 </span>414 <span className="shrink-0 text-sm font-semibold tabular-nums">415 {row.sub.toFixed(0)}416 </span>417 </Link>418 </li>419 ))}420 </ul>421 </div>422 </section>423424 <section className="card mt-12 p-6">425 <h2 className="font-semibold">How to read these numbers</h2>426 <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">427 "Wage bill under pressure" weights every occupation's wages by its substitution score —428 it measures where paid work overlaps with what AI can plausibly take over, not a payroll429 forecast. Realized effects so far are concentrated and cohort-specific, and430 augmentation-weighted usage still dominates. Metrics cover the{" "}431 {agg.n.toLocaleString("en-US")} scored occupations with BLS wage and employment data;432 methodology and formulas are public on the{" "}433 <Link href="/methodology" className="underline">434 methodology page435 </Link>436 .437 </p>438 </section>439 </main>440 );441}442