// File: page.tsx // Path: apps/web/app/insights/page.tsx // Project: AI Risk Index — airiskindex.io // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // // Description: Insights — headline metrics: wage bill under pressure, workers in high-pressure occupations, augmentation counterpoint. import Link from "next/link"; import { prisma } from "@airiskindex/db"; import { HIGH_EXPOSURE_THRESHOLD } from "@airiskindex/scoring"; import { DecileColumns, ScatterPlot } from "@/components/insights-charts"; import { DistributionChart } from "@/components/score-marks"; import { SOC_MAJOR_GROUPS } from "@/lib/soc-groups"; export const dynamic = "force-dynamic"; export const metadata = { title: "Insights — AI Risk Index", description: "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.", }; interface Aggregates { n: number; total_emp: number; wage_bill: number; wage_at_risk: number; emp_weighted_sub: number; emp_weighted_aug: number; emp_high_sub: number; emp_high_aug: number; avg_ci_width: number; } function money(value: number): string { if (value >= 1e12) return `$${(value / 1e12).toFixed(2)}T`; if (value >= 1e9) return `$${(value / 1e9).toFixed(0)}B`; return `$${Math.round(value).toLocaleString("en-US")}`; } function millions(value: number): string { return value >= 1e6 ? `${(value / 1e6).toFixed(1)}M` : Math.round(value).toLocaleString("en-US"); } async function loadInsights() { const run = await prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } }); if (!run) return null; const [agg] = await prisma.$queryRaw` SELECT count(*)::int AS n, sum(o.employment)::float8 AS total_emp, sum(o.employment::float8 * o."medianWageCents"::float8 / 100.0)::float8 AS wage_bill, sum(o.employment::float8 * o."medianWageCents"::float8 / 100.0 * s.substitution / 100.0)::float8 AS wage_at_risk, (sum(o.employment * s.substitution) / sum(o.employment))::float8 AS emp_weighted_sub, (sum(o.employment * s.augmentation) / sum(o.employment))::float8 AS emp_weighted_aug, sum(CASE WHEN s.substitution >= 70 THEN o.employment ELSE 0 END)::float8 AS emp_high_sub, sum(CASE WHEN s.augmentation >= 70 THEN o.employment ELSE 0 END)::float8 AS emp_high_aug, avg(s."substitutionHigh" - s."substitutionLow")::float8 AS avg_ci_width FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" WHERE s."runId" = ${run.id} AND o.employment IS NOT NULL AND o."medianWageCents" IS NOT NULL `; const [taskShare] = await prisma.$queryRaw>` SELECT (count(*) FILTER (WHERE substitution >= ${HIGH_EXPOSURE_THRESHOLD}))::float8 / count(*) AS share, count(*) AS total FROM "TaskScore" WHERE "runId" = ${run.id} `; const [scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI] = await Promise.all([ prisma.$queryRaw>` SELECT s.substitution::float8 AS x, s.augmentation::float8 AS y, o.employment::float8 AS size, o.title AS label FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" WHERE s."runId" = ${run.id} `, prisma.$queryRaw>` SELECT left(s."occupationCode", 2) AS prefix, (sum(s.substitution * COALESCE(o.employment, 0)) / NULLIF(sum(COALESCE(o.employment, 0)), 0))::float8 AS sub, count(*)::int AS n, sum(COALESCE(o.employment, 0))::float8 AS emp FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" WHERE s."runId" = ${run.id} GROUP BY 1 HAVING sum(COALESCE(o.employment, 0)) > 0 ORDER BY 2 DESC `, prisma.$queryRaw>` WITH d AS ( SELECT s.substitution, o.employment::float8 AS emp, o."medianWageCents", ntile(10) OVER (ORDER BY o."medianWageCents") AS decile FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" WHERE s."runId" = ${run.id} AND o."medianWageCents" IS NOT NULL AND o.employment IS NOT NULL ) SELECT decile::int, (sum(substitution * emp) / sum(emp))::float8 AS sub, min("medianWageCents")::float8 AS lo, max("medianWageCents")::float8 AS hi FROM d GROUP BY decile ORDER BY decile `, prisma.occupationScore.findMany({ where: { runId: run.id }, orderBy: { substitution: "desc" }, take: 20, include: { occupation: { select: { code: true, title: true, medianWageCents: true, employment: true } }, }, }), prisma.occupationScore.findMany({ where: { runId: run.id }, select: { substitution: true }, }), prisma.$queryRaw>` SELECT o.code, o.title, o.employment::float8 AS employment, s.substitution::float8 AS sub FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" WHERE s."runId" = ${run.id} AND s.substitution >= 60 AND o.employment IS NOT NULL ORDER BY o.employment DESC LIMIT 6 `, prisma.$queryRaw>` SELECT o.code, o.title, (s."substitutionHigh" - s."substitutionLow")::float8 AS width, s.substitution::float8 AS sub FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" WHERE s."runId" = ${run.id} ORDER BY 3 DESC LIMIT 6 `, ]); return { run, agg, taskShare, scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI }; } function toBins(values: number[], binCount = 20): number[] { const bins = Array.from({ length: binCount }, () => 0); for (const value of values) { bins[Math.min(binCount - 1, Math.floor((value / 100) * binCount))] += 1; } return bins; } const wageShort = (cents: number): string => `$${Math.round(cents / 100_000)}k`; function Tile({ label, value, detail, }: { label: string; value: string; detail: string; }): JSX.Element { return (

{label}

{value}

{detail}

); } export default async function InsightsPage(): Promise { const data = await loadInsights(); if (!data) { return (

Insights

No score run published yet.

); } const { run, agg, taskShare, scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI } = data; const atRiskShare = (agg.wage_at_risk / agg.wage_bill) * 100; return (

Insights · run {run.indexVersion} · {run.createdAt.toISOString().slice(0, 10)}

What the index says, in aggregate

Employment- and wage-weighted headline metrics across {agg.n.toLocaleString("en-US")}{" "} scored occupations covering {millions(agg.total_emp)} U.S. workers (BLS OEWS wages and employment). Pressure, not prophecy — see how to read this below.

{/* Hero figure */}

Share of the U.S. wage bill under substitution pressure

{atRiskShare.toFixed(0)} %

≈ {money(agg.wage_at_risk)} of {money(agg.wage_bill)} in annual median wages, substitution-score-weighted across occupations. This measures how much paid work sits in exposed tasks — not wages that will disappear.

{/* The signature chart: substitution × augmentation */}

Substitution × augmentation — every occupation

The core claim of the methodology in one picture: replacement and assistance are different axes. Each dot is one of {scatter.length.toLocaleString("en-US")} occupations; dot area reflects U.S. employment. Hover a dot for its name.

The shape of the index

Distribution of all {allScores.length.toLocaleString("en-US")} occupation substitution scores; the marker is the employment-weighted mean.

s.substitution))} marker={agg.emp_weighted_sub} markerLabel={`workers' mean · ${agg.emp_weighted_sub.toFixed(0)}`} />

Pressure by wage decile

Employment-weighted mean substitution per median-wage decile — where on the pay scale the pressure sits.

({ label: `${wageShort(d.lo)}–${wageShort(d.hi)}`, value: d.sub, }))} />

By occupation group

Employment-weighted mean substitution per SOC major group.

{groups.map((group) => (
{SOC_MAJOR_GROUPS[group.prefix] ?? group.prefix} {group.n} occ. · {millions(group.emp)} workers {group.sub.toFixed(0)}

Top 20 by substitution

full index →
{top20.map((row, index) => ( ))}
Top 20 occupations by substitution score
# Occupation Substitution (CI) Exposure Augment. Median wage Employed
{index + 1} {row.occupation.title} {row.substitution.toFixed(1)} ({row.substitutionLow.toFixed(0)}–{row.substitutionHigh.toFixed(0)}) {row.exposure.toFixed(0)} {row.augmentation.toFixed(0)} {row.occupation.medianWageCents != null ? `$${Math.round(row.occupation.medianWageCents / 100).toLocaleString("en-US")}` : "—"} {row.occupation.employment != null ? row.occupation.employment.toLocaleString("en-US") : "—"}

Most workers in high-pressure occupations

Substitution ≥ 60, ordered by U.S. employment.

    {mostWorkersAtHigh.map((row) => (
  • {row.title} {millions(row.employment)} workers {row.sub.toFixed(0)}
  • ))}

Where the panel disagrees most

Widest confidence intervals — scores to hold loosely, and first in line for expert review.

    {widestCI.map((row) => (
  • {row.title} ±{(row.width / 2).toFixed(0)} {row.sub.toFixed(0)}
  • ))}

How to read these numbers

"Wage bill under pressure" weights every occupation's wages by its substitution score — it measures where paid work overlaps with what AI can plausibly take over, not a payroll forecast. Realized effects so far are concentrated and cohort-specific, and augmentation-weighted usage still dominates. Metrics cover the{" "} {agg.n.toLocaleString("en-US")} scored occupations with BLS wage and employment data; methodology and formulas are public on the{" "} methodology page .

); }