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%
19.9 KB · 442 lines tsx
Raw Blame History
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