diff --git a/.env.example b/.env.example index 4ccec424..58bc57b8 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,9 @@ NEXT_PUBLIC_COOKIE_CONSENT=on # GraphQL indexer URL for org.plresearch.* records # Default: https://api.hi.gainforest.app/graphql # INDEXER_URL=http://localhost:4000/graphql + +# Metaculus API token — enables live community-forecast readings on the /impact +# dashboard (e.g. the whole-brain-emulation question). Without it the question +# is still linked, just without a live number. Create a token at +# https://www.metaculus.com/accounts/settings/ +# METACULUS_API_TOKEN= diff --git a/docs/screenshots/README-impact-v2.md b/docs/screenshots/README-impact-v2.md new file mode 100644 index 00000000..f0abb745 --- /dev/null +++ b/docs/screenshots/README-impact-v2.md @@ -0,0 +1,23 @@ +# Impact dashboard — v1 vs v2 (side-by-side) + +Screenshots for the stacked PR that adds the acceleration-centered `/impact-v2` +alongside the unchanged inflection-point `/impact` (PR #29). Captured at ~1280×600. + +## v2 — `/impact-v2` (acceleration reframe) + +| File | What it shows | +|---|---| +| `v2-light-cards.png` / `v2-dark-cards.png` | Field card leads with the **velocity basket**; hand card shows tool → condition → observed movement | +| `v2-light-sensors.png` / `v2-dark-sensors.png` | "Our sensors: inflection points" list with status chips (incl. **RETIRED**, illustrative-draft caption) | +| `v2-light-sensor-modal.png` | **Retired** sensor modal — status + logged post-mortem | +| `v2-dark-sensor-modal.png` | **Missed · field moved** sensor modal — distinct amber post-mortem | +| `v2-dark-toolkit.png` | Methodology: the seven-tool toolkit with `releases:` tags + "needs co-funders" markers | +| `v2-dark-toolkit-modal.png` | Toolkit modal (Culture) — full description + what-changed + co-funder gap | +| `v2-light-howtoread-modal.png` | How-to-read v2 — X→Y contract + two-axis legend + five sensor-status legend | + +## v1 — `/impact` (unchanged from PR #29) + +| File | What it shows | +|---|---| +| `v1-light-cards.png` / `v1-dark-cards.png` | Original inflection-point cards (The field / Our hand / Live signal) | +| `v1-light-modal.png` / `v1-dark-modal.png` | Original stacked grey-card detail modal | diff --git a/docs/screenshots/impact-methodology-interventions-dark.png b/docs/screenshots/impact-methodology-interventions-dark.png new file mode 100644 index 00000000..d37170a9 Binary files /dev/null and b/docs/screenshots/impact-methodology-interventions-dark.png differ diff --git a/docs/screenshots/impact-methodology-interventions-light.png b/docs/screenshots/impact-methodology-interventions-light.png new file mode 100644 index 00000000..c9187f3a Binary files /dev/null and b/docs/screenshots/impact-methodology-interventions-light.png differ diff --git a/docs/screenshots/impact-modal-restructured.png b/docs/screenshots/impact-modal-restructured.png new file mode 100644 index 00000000..6671c3f7 Binary files /dev/null and b/docs/screenshots/impact-modal-restructured.png differ diff --git a/docs/screenshots/impact-signals-grid.png b/docs/screenshots/impact-signals-grid.png new file mode 100644 index 00000000..510e6419 Binary files /dev/null and b/docs/screenshots/impact-signals-grid.png differ diff --git a/docs/screenshots/impact-signals-livebar.png b/docs/screenshots/impact-signals-livebar.png new file mode 100644 index 00000000..c938d6af Binary files /dev/null and b/docs/screenshots/impact-signals-livebar.png differ diff --git a/docs/screenshots/impact-signals-modal.png b/docs/screenshots/impact-signals-modal.png new file mode 100644 index 00000000..1f841009 Binary files /dev/null and b/docs/screenshots/impact-signals-modal.png differ diff --git a/docs/screenshots/impact-signals-platforms.png b/docs/screenshots/impact-signals-platforms.png new file mode 100644 index 00000000..0b9e5eb3 Binary files /dev/null and b/docs/screenshots/impact-signals-platforms.png differ diff --git a/docs/screenshots/inflection-card-dark.png b/docs/screenshots/inflection-card-dark.png new file mode 100644 index 00000000..1bc104bd Binary files /dev/null and b/docs/screenshots/inflection-card-dark.png differ diff --git a/docs/screenshots/inflection-card-light.png b/docs/screenshots/inflection-card-light.png new file mode 100644 index 00000000..1be8c775 Binary files /dev/null and b/docs/screenshots/inflection-card-light.png differ diff --git a/docs/screenshots/methodology-idea-vintage-modal.png b/docs/screenshots/methodology-idea-vintage-modal.png new file mode 100644 index 00000000..2e0987e9 Binary files /dev/null and b/docs/screenshots/methodology-idea-vintage-modal.png differ diff --git a/docs/screenshots/methodology-inflection-quadrant-modal.png b/docs/screenshots/methodology-inflection-quadrant-modal.png new file mode 100644 index 00000000..a0464e3c Binary files /dev/null and b/docs/screenshots/methodology-inflection-quadrant-modal.png differ diff --git a/docs/screenshots/v1-dark-cards.png b/docs/screenshots/v1-dark-cards.png new file mode 100644 index 00000000..655b5d53 Binary files /dev/null and b/docs/screenshots/v1-dark-cards.png differ diff --git a/docs/screenshots/v1-dark-modal.png b/docs/screenshots/v1-dark-modal.png new file mode 100644 index 00000000..7e1d3300 Binary files /dev/null and b/docs/screenshots/v1-dark-modal.png differ diff --git a/docs/screenshots/v1-light-cards.png b/docs/screenshots/v1-light-cards.png new file mode 100644 index 00000000..45d6fbaf Binary files /dev/null and b/docs/screenshots/v1-light-cards.png differ diff --git a/docs/screenshots/v1-light-modal.png b/docs/screenshots/v1-light-modal.png new file mode 100644 index 00000000..c6f95f5d Binary files /dev/null and b/docs/screenshots/v1-light-modal.png differ diff --git a/docs/screenshots/v2-dark-cards.png b/docs/screenshots/v2-dark-cards.png new file mode 100644 index 00000000..22e9217c Binary files /dev/null and b/docs/screenshots/v2-dark-cards.png differ diff --git a/docs/screenshots/v2-dark-sensor-modal.png b/docs/screenshots/v2-dark-sensor-modal.png new file mode 100644 index 00000000..3b0ec87f Binary files /dev/null and b/docs/screenshots/v2-dark-sensor-modal.png differ diff --git a/docs/screenshots/v2-dark-sensors.png b/docs/screenshots/v2-dark-sensors.png new file mode 100644 index 00000000..5c3d8b46 Binary files /dev/null and b/docs/screenshots/v2-dark-sensors.png differ diff --git a/docs/screenshots/v2-dark-toolkit-modal.png b/docs/screenshots/v2-dark-toolkit-modal.png new file mode 100644 index 00000000..32af31bf Binary files /dev/null and b/docs/screenshots/v2-dark-toolkit-modal.png differ diff --git a/docs/screenshots/v2-dark-toolkit.png b/docs/screenshots/v2-dark-toolkit.png new file mode 100644 index 00000000..d8856d85 Binary files /dev/null and b/docs/screenshots/v2-dark-toolkit.png differ diff --git a/docs/screenshots/v2-light-cards.png b/docs/screenshots/v2-light-cards.png new file mode 100644 index 00000000..d5022c1e Binary files /dev/null and b/docs/screenshots/v2-light-cards.png differ diff --git a/docs/screenshots/v2-light-howtoread-modal.png b/docs/screenshots/v2-light-howtoread-modal.png new file mode 100644 index 00000000..67f27062 Binary files /dev/null and b/docs/screenshots/v2-light-howtoread-modal.png differ diff --git a/docs/screenshots/v2-light-sensor-modal.png b/docs/screenshots/v2-light-sensor-modal.png new file mode 100644 index 00000000..53e129f5 Binary files /dev/null and b/docs/screenshots/v2-light-sensor-modal.png differ diff --git a/docs/screenshots/v2-light-sensors.png b/docs/screenshots/v2-light-sensors.png new file mode 100644 index 00000000..3e745ffb Binary files /dev/null and b/docs/screenshots/v2-light-sensors.png differ diff --git a/docs/screenshots/velocity-box-openalex-dark.png b/docs/screenshots/velocity-box-openalex-dark.png new file mode 100644 index 00000000..9f44b2f6 Binary files /dev/null and b/docs/screenshots/velocity-box-openalex-dark.png differ diff --git a/docs/screenshots/velocity-box-openalex-light.png b/docs/screenshots/velocity-box-openalex-light.png new file mode 100644 index 00000000..99f08f2f Binary files /dev/null and b/docs/screenshots/velocity-box-openalex-light.png differ diff --git a/docs/screenshots/velocity-modal-dark.png b/docs/screenshots/velocity-modal-dark.png new file mode 100644 index 00000000..ee3d0c12 Binary files /dev/null and b/docs/screenshots/velocity-modal-dark.png differ diff --git a/docs/screenshots/velocity-modal-light.png b/docs/screenshots/velocity-modal-light.png new file mode 100644 index 00000000..ab0dfcb9 Binary files /dev/null and b/docs/screenshots/velocity-modal-light.png differ diff --git a/package.json b/package.json index bfa59480..4273cd97 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "node scripts/build-content.mjs && next dev", "build": "node scripts/build-content.mjs && next build", "start": "next start", - "generate-jwk": "node scripts/generate-jwk.js" + "generate-jwk": "node scripts/generate-jwk.js", + "test": "node --test scripts/velocity/*.test.mjs" }, "dependencies": { "@atproto/api": "^0.18.17", diff --git a/scripts/velocity/README.md b/scripts/velocity/README.md new file mode 100644 index 00000000..71e64769 --- /dev/null +++ b/scripts/velocity/README.md @@ -0,0 +1,23 @@ +# scripts/velocity + +The OpenAlex generator and the direction math for the field-velocity +**idea vintage** instrument. + +- `field_velocity_openalex.py` — queries OpenAlex and writes one CSV per focus + area into `src/data/velocity/`. **This file is added unchanged from Lukas's + working copy; it is not reconstructed here.** Run it yourself only if you have + network access and an email to pass: + + ``` + python scripts/velocity/field_velocity_openalex.py \ + --search "content addressing|peer-to-peer network|censorship resistance|decentralized storage" \ + --from-year 2005 --email you@example.org \ + --out src/data/velocity/digital-human-rights.csv + ``` + + See `src/data/velocity/README.md` for the CSV contract and the frozen query + strings per focus area. + +- `vintage-direction.mjs` — the pure, inverted-direction helper. Falling median + reference age = accelerating. Imported by the TS ingestion lib and by the test. +- `vintage-direction.test.mjs` — run with `npm test` (`node --test`). diff --git a/scripts/velocity/field_velocity_openalex.py b/scripts/velocity/field_velocity_openalex.py new file mode 100644 index 00000000..de2bb8f6 --- /dev/null +++ b/scripts/velocity/field_velocity_openalex.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +""" +field_velocity_openalex.py — build one field-velocity CSV per focus area from +OpenAlex (CC0). Real data only; no synthesised values. + +Per publication year Y it emits: + year,field_works,all_works,corpus_share_per_100k,first_time_authors, + idea_vintage_median_years,vintage_ci_lo,vintage_ci_hi,n_refs_sampled,reliable + +Idea vintage = the median AGE of the references cited by new work in year Y, +where age = Y - (referenced work's publication year). We draw a random sample of +works per year (OpenAlex `sample`), resolve their referenced works' years in +batches, pool the ages, take the median, and bootstrap a 95% CI. + +Recent years (>= reliable_cutoff_year) are undercounted by OpenAlex indexing lag +and are flagged reliable=0. + +Queries hit the FILTER cluster (`filter=title_and_abstract.search:...`), not the +anonymous fulltext `search=` cluster, which is currently rate-limited. `|` ORs +the frozen terms. A free API key (--api-key) lifts rate limits but is optional. + +Usage: + python scripts/velocity/field_velocity_openalex.py \ + --search "content addressing|peer-to-peer network|censorship resistance|decentralized storage" \ + --from-year 2005 --email lukas@protocol.ai \ + --out src/data/velocity/digital-human-rights.csv +""" +import argparse +import datetime as dt +import json +import random +import ssl +import statistics +import sys +import time +import urllib.parse +import urllib.request + +API = "https://api.openalex.org/works" + + +def ssl_context(insecure=False): + if insecure: + return ssl._create_unverified_context() # noqa: S323 — only for broken CA envs + try: + import certifi + + return ssl.create_default_context(cafile=certifi.where()) + except Exception: # noqa: BLE001 + return ssl.create_default_context() + + +SSL_CTX = ssl.create_default_context() + + +def build_url(params): + return API + "?" + urllib.parse.urlencode(params, safe="|:,") + + +def fetch(params, email, api_key, tries=6): + params = dict(params) + if email: + params["mailto"] = email + if api_key: + params["api_key"] = api_key + url = build_url(params) + delay = 2.0 + for attempt in range(tries): + try: + req = urllib.request.Request(url, headers={"User-Agent": f"plrd-field-velocity (mailto:{email})"}) + with urllib.request.urlopen(req, timeout=60, context=SSL_CTX) as r: + return json.load(r) + except urllib.error.HTTPError as e: + if e.code in (429, 503, 500, 502, 504): + retry = e.headers.get("retryAfter") or e.headers.get("Retry-After") + # Cap the wait: anonymous search can hand back multi-hour backoffs. + # Fail fast instead of sleeping for hours; use an API key to lift limits. + wait = min(float(retry) + 1 if retry else delay, 120.0) + print(f" … {e.code}, waiting {wait:.0f}s (attempt {attempt+1})", file=sys.stderr) + time.sleep(wait) + delay = min(delay * 2, 60) + continue + raise + except Exception as ex: # noqa: BLE001 + print(f" … {ex}, retrying (attempt {attempt+1})", file=sys.stderr) + time.sleep(delay) + delay = min(delay * 2, 60) + raise RuntimeError(f"failed after {tries} attempts: {url}") + + +def count(filt, email, api_key): + d = fetch({"filter": filt, "per-page": 1}, email, api_key) + return d["meta"]["count"] + + +def resolve_ref_years(ref_ids, email, api_key, batch=50): + years = {} + for i in range(0, len(ref_ids), batch): + chunk = ref_ids[i : i + batch] + d = fetch( + {"filter": "openalex_id:" + "|".join(chunk), "select": "id,publication_year", "per-page": batch}, + email, + api_key, + ) + for w in d.get("results", []): + wid = w["id"].split("/")[-1] + if w.get("publication_year"): + years[wid] = w["publication_year"] + time.sleep(0.2) + return years + + +def bootstrap_ci(ages, iters=500): + if len(ages) < 8: + return (None, None) + rng = random.Random(42) + meds = [] + n = len(ages) + for _ in range(iters): + sample = [ages[rng.randrange(n)] for _ in range(n)] + meds.append(statistics.median(sample)) + meds.sort() + return (round(meds[int(0.025 * iters)], 2), round(meds[int(0.975 * iters)], 2)) + + +def year_row(year, search, all_works, works_per_year, max_refs, email, api_key): + field_filt = f"title_and_abstract.search:{search},publication_year:{year}" + field_works = count(field_filt, email, api_key) + corpus_share = round(field_works / all_works * 1e5, 3) if all_works else 0.0 + + # Random sample of works this year, with their references + authors. + d = fetch( + { + "filter": field_filt, + "sample": min(works_per_year, max(field_works, 1)), + "seed": 42, + "per-page": min(works_per_year, 200), + "select": "id,publication_year,referenced_works", + "mailto": email, + }, + email, + api_key, + ) + works = d.get("results", []) + + ref_ids = [] + for w in works: + refs = [r.split("/")[-1] for r in (w.get("referenced_works") or [])] + ref_ids += refs[:max_refs] + ref_ids = list(dict.fromkeys(ref_ids)) # dedupe, keep order + + ref_years = resolve_ref_years(ref_ids, email, api_key) if ref_ids else {} + ages = [year - y for y in ref_years.values() if y and 1900 <= y <= year] + median = round(statistics.median(ages), 2) if ages else None + lo, hi = bootstrap_ci(ages) if ages else (None, None) + + return { + "year": year, + "field_works": field_works, + "all_works": all_works, + "corpus_share_per_100k": corpus_share, + "idea_vintage_median_years": median, + "vintage_ci_lo": lo, + "vintage_ci_hi": hi, + "n_refs_sampled": len(ages), + } + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--search", required=True, help="frozen query, | = OR") + ap.add_argument("--from-year", type=int, required=True) + ap.add_argument("--to-year", type=int, default=dt.date.today().year) + ap.add_argument("--email", default="") + ap.add_argument("--api-key", default="") + ap.add_argument("--works-per-year", type=int, default=120) + ap.add_argument("--max-refs-per-work", type=int, default=60) + ap.add_argument("--reliable-cutoff-year", type=int, default=dt.date.today().year - 2) + ap.add_argument("--insecure", action="store_true", help="skip TLS verification (broken CA bundles only)") + ap.add_argument("--out", required=True) + a = ap.parse_args() + + global SSL_CTX + SSL_CTX = ssl_context(a.insecure) + + all_works_by_year = {} + rows = [] + for year in range(a.from_year, a.to_year + 1): + if year not in all_works_by_year: + all_works_by_year[year] = count(f"publication_year:{year}", a.email, a.api_key) + print(f"[{year}] querying…", file=sys.stderr) + row = year_row(year, a.search, all_works_by_year[year], a.works_per_year, a.max_refs_per_work, a.email, a.api_key) + # first_time_authors is left blank: a robust population-level count needs + # per-author first-in-field history (not a constant-size sample), so we do + # not emit a biased proxy. idea vintage is the wired signal here. + row["first_time_authors"] = "" + row["reliable"] = 0 if year >= a.reliable_cutoff_year else 1 + rows.append(row) + print( + f" field_works={row['field_works']} median={row['idea_vintage_median_years']} " + f"n_refs={row['n_refs_sampled']}", + file=sys.stderr, + ) + + generated = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + header = ( + "year,field_works,all_works,corpus_share_per_100k,first_time_authors," + "idea_vintage_median_years,vintage_ci_lo,vintage_ci_hi,n_refs_sampled,reliable" + ) + lines = [ + f"# generated: {generated}", + "# source: OpenAlex (https://openalex.org, CC0)", + f"# query: {a.search}", + f"# from_year: {a.from_year}", + f"# reliable_cutoff_year: {a.reliable_cutoff_year}", + f"# sample: works_per_year={a.works_per_year}; max_refs_per_work={a.max_refs_per_work}; " + "cluster=title_and_abstract.search; note=first_time_authors intentionally blank (needs per-author history)", + header, + ] + + def cell(v): + return "" if v is None else str(v) + + for r in rows: + lines.append( + ",".join( + cell(r[k]) + for k in [ + "year", + "field_works", + "all_works", + "corpus_share_per_100k", + "first_time_authors", + "idea_vintage_median_years", + "vintage_ci_lo", + "vintage_ci_hi", + "n_refs_sampled", + "reliable", + ] + ) + ) + with open(a.out, "w") as f: + f.write("\n".join(lines) + "\n") + print(f"wrote {a.out} ({len(rows)} rows)", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/scripts/velocity/vintage-direction.mjs b/scripts/velocity/vintage-direction.mjs new file mode 100644 index 00000000..36874973 --- /dev/null +++ b/scripts/velocity/vintage-direction.mjs @@ -0,0 +1,64 @@ +// Direction math for the idea-vintage instrument. +// +// The sign is INVERTED for this instrument: a FALLING median reference age means +// new work is building on fresher ideas, i.e. the field is ACCELERATING. +// +// The series is a noisy sampled estimate, so we do NOT read a single-year slope +// or two raw endpoints (those contradict each other and over-claim). Instead we +// compare the MEAN of the earliest few reliable years against the MEAN of the +// most recent few, and only call a direction when the shift clears a flat band +// (roughly the size of the sampling scatter). The dashboard's comparison text +// and the direction chip are both derived from this one calculation, so they can +// never disagree. +// +// Pure ES module (no TypeScript, no deps) so it can be imported by both the +// Next build (via the TS ingestion lib) and `node --test`. + +/** Least-squares slope (y per unit x), used for rising-is-good count series. */ +export function trailingSlope(points, window = 5) { + const pts = points.slice(-window) + const n = pts.length + if (n < 2) return null + let sx = 0 + let sy = 0 + let sxx = 0 + let sxy = 0 + for (const p of pts) { + sx += p.x + sy += p.y + sxx += p.x * p.x + sxy += p.x * p.y + } + const denom = n * sxx - sx * sx + if (denom === 0) return null + return (n * sxy - sx * sy) / denom +} + +/** Mean of the earliest and most recent `w` reliable points (sorted ascending). */ +export function windowMeans(points, w = 3) { + if (!points.length) return null + const k = Math.min(w, points.length) + const mean = (arr) => arr.reduce((s, p) => s + p.y, 0) / arr.length + const early = points.slice(0, k) + const late = points.slice(-k) + return { + early: mean(early), + late: mean(late), + earlyRange: [early[0].x, early[early.length - 1].x], + lateRange: [late[0].x, late[late.length - 1].x], + } +} + +/** + * Direction of the idea-vintage instrument from reliable points only. + * `flat` is the band (in median-years) within which we call it flat rather than + * claim a trend. Returns 'accelerating' | 'flat' | 'decelerating' | 'unclear'. + */ +export function ideaVintageDirection(reliablePoints, { w = 3, flat = 0.75 } = {}) { + if (!reliablePoints || reliablePoints.length < 2) return 'unclear' + const m = windowMeans(reliablePoints, w) + if (!m) return 'unclear' + const delta = m.late - m.early // rising reference age = older ideas + if (Math.abs(delta) <= flat) return 'flat' + return delta < 0 ? 'accelerating' : 'decelerating' // falling age (inverted) = accelerating +} diff --git a/scripts/velocity/vintage-direction.test.mjs b/scripts/velocity/vintage-direction.test.mjs new file mode 100644 index 00000000..53b8f0ca --- /dev/null +++ b/scripts/velocity/vintage-direction.test.mjs @@ -0,0 +1,63 @@ +// Unit test for the inverted idea-vintage direction. Run with `npm test`. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { ideaVintageDirection, windowMeans } from './vintage-direction.mjs' + +test('a clearly falling median reference age reads as accelerating (sign is inverted)', () => { + const pts = [ + { x: 2016, y: 12 }, + { x: 2017, y: 11 }, + { x: 2018, y: 10 }, + { x: 2019, y: 9 }, + { x: 2020, y: 8 }, + ] + assert.equal(ideaVintageDirection(pts), 'accelerating') +}) + +test('a clearly rising median reference age reads as decelerating', () => { + const pts = [ + { x: 2016, y: 8 }, + { x: 2017, y: 9 }, + { x: 2018, y: 10 }, + { x: 2019, y: 11 }, + { x: 2020, y: 12 }, + ] + assert.equal(ideaVintageDirection(pts), 'decelerating') +}) + +test('a small, in-noise change reads as flat (this was the E&G bug: 6y->7y is not "accelerating")', () => { + // Mimics the noisy E&G series: bounces 5-8y, early mean ~ late mean. + const pts = [ + { x: 2005, y: 6 }, + { x: 2006, y: 5 }, + { x: 2007, y: 7 }, + { x: 2019, y: 8 }, + { x: 2020, y: 5 }, + { x: 2021, y: 7 }, + { x: 2022, y: 6 }, + { x: 2023, y: 7 }, + ] + assert.equal(ideaVintageDirection(pts), 'flat') +}) + +test('fewer than two points is unclear', () => { + assert.equal(ideaVintageDirection([{ x: 2022, y: 10 }]), 'unclear') +}) + +test('windowMeans averages the first and last w points', () => { + const m = windowMeans( + [ + { x: 2005, y: 6 }, + { x: 2006, y: 6 }, + { x: 2007, y: 6 }, + { x: 2021, y: 9 }, + { x: 2022, y: 9 }, + { x: 2023, y: 9 }, + ], + 3, + ) + assert.equal(m.early, 6) + assert.equal(m.late, 9) + assert.deepEqual(m.earlyRange, [2005, 2007]) + assert.deepEqual(m.lateRange, [2021, 2023]) +}) diff --git a/src/app/globals.css b/src/app/globals.css index 1548fae9..185d7543 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -95,6 +95,14 @@ body { background-color: var(--color-white); } +/* Impact dashboard two-axis accents — theme-aware so the “field” ink and the + progress track stay legible in dark mode (impact field-velocity dashboard). */ +:root { + --impact-field: #131316; /* the field axis (neutral ink) */ + --impact-field-ink: #ffffff; /* text on a field-accent fill */ + --impact-hand: #1982f4; /* our-hand axis (blue) */ +} + /* ===== Dark mode ===== Rather than annotating every component, we remap the semantic color tokens. Because Tailwind v4 utilities compile to var(--color-*), @@ -120,6 +128,12 @@ html.dark { --color-gray: #9A9EAB; --color-blue: #3B96F6; /* slightly brighter accents on dark */ + + /* Two-axis accents: light ink reads on dark surfaces; the field fill flips + to light so white badge text is swapped for dark ink. */ + --impact-field: #e9e9ee; + --impact-field-ink: #15171c; + --impact-hand: #3b96f6; } /* Keep the footer's intentional dark design in both themes by diff --git a/src/app/impact-preview-eb61fba1b98e/page.tsx b/src/app/impact-preview-eb61fba1b98e/page.tsx new file mode 100644 index 00000000..e2cb684c --- /dev/null +++ b/src/app/impact-preview-eb61fba1b98e/page.tsx @@ -0,0 +1,148 @@ +import type { Metadata } from 'next' +import Breadcrumb from '@/components/Breadcrumb' +import ImpactDashboardV2, { type LiveMetric, type LiveOutputs } from '@/components/ImpactDashboardV2' +import MeasuringQuestionsV2 from '@/components/MeasuringQuestionsV2' +import { fetchSimocracyStats } from '@/lib/simocracy' +import { fetchGainforestStats } from '@/lib/gainforest' +import { fetchGlowStats } from '@/lib/glow' +import { resolveAllSignals } from '@/lib/market-signals' +import { FOCUS_AREAS, type FocusAreaKey } from '@/lib/inflection-points' +import { instrumentsForArea, withOpenAlex, type InstrumentRecord } from '@/lib/velocity-instruments' +import { loadAllOpenAlex } from '@/lib/velocity-openalex' + +// The impact page reads field velocity: the interventions we run, the five +// instruments we read a field's rate of change with, and the inflection points +// we track, each with its live signal. +export const revalidate = 60 + +async function fetchLiveOutputs(): Promise { + const compact = (n: number) => + new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(n) + const out: LiveOutputs = {} + + const [sim, gf, glow] = await Promise.allSettled([ + fetchSimocracyStats(), + fetchGainforestStats(), + fetchGlowStats(), + ]) + + // A binding decision at scale — Simocracy (a PL-supported deliberation mechanism). + if (sim.status === 'fulfilled' && !sim.value.degraded) { + const t = sim.value.totals + const metrics: LiveMetric[] = [ + { n: t.uniqueHumans, label: 'participants' }, + { n: t.totalSims, label: 'simulations' }, + { n: t.totalGatherings, label: 'gatherings' }, + ] + .filter((m) => m.n > 0) + .map((m) => ({ value: compact(m.n), label: m.label })) + if (metrics.length) out['A binding decision at scale'] = metrics + } + + // Capital that pays on verified outcomes — GainForest + Glow (PL-backed MRV teams). + const verified: LiveMetric[] = [] + if (gf.status === 'fulfilled' && !gf.value.degraded) { + const g = gf.value + if (g.observations > 0) verified.push({ value: compact(g.observations), label: 'species observations' }) + if (g.certifiedOrgs > 0) verified.push({ value: compact(g.certifiedOrgs), label: 'certified orgs' }) + } + if (glow.status === 'fulfilled' && !glow.value.degraded) { + const gl = glow.value + if (gl.activeFarms > 0) verified.push({ value: compact(gl.activeFarms), label: 'active solar farms' }) + if (gl.carbon > 0) verified.push({ value: compact(gl.carbon), label: 'tCO₂ / wk' }) + } + if (verified.length) out['Capital that pays on verified outcomes'] = verified + + return out +} + +export const metadata: Metadata = { + title: 'Impact', + description: + 'How we judge PL R&D: whether the fields we back are speeding up. We name the interventions we run, then read field velocity through five instruments and the inflection points we track.', + // Unlisted preview: reachable only via the exact cryptic URL. Keep it out of + // search indexes and previews. (Not in nav, not in sitemap.xml, and + // deliberately not listed in robots.txt so the path isn't published there.) + robots: { index: false, follow: false, googleBot: { index: false, follow: false } }, +} + +export default async function ImpactPage() { + const [liveOutputs, marketSignals] = await Promise.all([fetchLiveOutputs(), resolveAllSignals()]) + + // Merge any OpenAlex CSV readings (idea vintage + talent entry) into the static + // instrument records, per focus area. Parsed at build time; absent CSVs are a + // no-op, leaving the documented `unwired` records in place. + const openAlex = loadAllOpenAlex() + const recordsByArea = Object.fromEntries( + FOCUS_AREAS.map((fa) => [fa.key, withOpenAlex(instrumentsForArea(fa.key), openAlex[fa.key])]), + ) as Partial> + + // Example idea-vintage series per field, for the methodology modal that explains + // the instrument (shown as small multiples). + const ideaVintageExamples = FOCUS_AREAS.map((fa) => { + const rec = (recordsByArea[fa.key] ?? []).find( + (r) => r.instrument === 'idea_vintage' && r.state === 'reading' && r.series && r.series.length > 1, + ) + return rec ? { label: fa.label, series: rec.series!, scale: rec.seriesScale ?? 'linear' } : null + }).filter((x): x is { label: string; series: NonNullable; scale: 'linear' | 'log' } => !!x) + return ( +
+ {/* Hero */} +
+ +
+

+ PL R&D’s impact on field velocity +

+

+ We back fields we think are ready to move, then check whether they do.{' '} + Field velocity is that rate of change: + how fast talent enters, capital forms, tool costs fall, and output ships.{' '} + Inflection points are one of the markers + we read — dated, falsifiable shifts an accelerating field should produce. +

+

+ We measure velocity the same way we do the work: as a research program. Whether field + acceleration works is itself the open question, tested across all four focus areas. +

+ + Learn more about our methodology + + + + +
+
+ + {/* Field velocity — grey full-bleed section to set it apart from the rest of the site */} +
+
+

Field velocity

+

+ Pick a focus area. The summary above reads that field’s velocity across the instruments + that apply to it; the inflection points below are the specific markers we track, each with its + live signal. +

+ +
+
+ + {/* Methodology */} +
+

Our methodology

+

+ The method is the meta-research design of how we do field acceleration. We name the + interventions we run, then read field velocity as the result. Same design, every focus area. +

+ +
+
+ ) +} diff --git a/src/app/impact/page.tsx b/src/app/impact/page.tsx new file mode 100644 index 00000000..82f401a2 --- /dev/null +++ b/src/app/impact/page.tsx @@ -0,0 +1,144 @@ +import type { Metadata } from 'next' +import Breadcrumb from '@/components/Breadcrumb' +import ImpactDashboardV2, { type LiveMetric, type LiveOutputs } from '@/components/ImpactDashboardV2' +import MeasuringQuestionsV2 from '@/components/MeasuringQuestionsV2' +import { fetchSimocracyStats } from '@/lib/simocracy' +import { fetchGainforestStats } from '@/lib/gainforest' +import { fetchGlowStats } from '@/lib/glow' +import { resolveAllSignals } from '@/lib/market-signals' +import { FOCUS_AREAS, type FocusAreaKey } from '@/lib/inflection-points' +import { instrumentsForArea, withOpenAlex, type InstrumentRecord } from '@/lib/velocity-instruments' +import { loadAllOpenAlex } from '@/lib/velocity-openalex' + +// The impact page reads field velocity: the interventions we run, the five +// instruments we read a field's rate of change with, and the inflection points +// we track, each with its live signal. +export const revalidate = 60 + +async function fetchLiveOutputs(): Promise { + const compact = (n: number) => + new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(n) + const out: LiveOutputs = {} + + const [sim, gf, glow] = await Promise.allSettled([ + fetchSimocracyStats(), + fetchGainforestStats(), + fetchGlowStats(), + ]) + + // A binding decision at scale — Simocracy (a PL-supported deliberation mechanism). + if (sim.status === 'fulfilled' && !sim.value.degraded) { + const t = sim.value.totals + const metrics: LiveMetric[] = [ + { n: t.uniqueHumans, label: 'participants' }, + { n: t.totalSims, label: 'simulations' }, + { n: t.totalGatherings, label: 'gatherings' }, + ] + .filter((m) => m.n > 0) + .map((m) => ({ value: compact(m.n), label: m.label })) + if (metrics.length) out['A binding decision at scale'] = metrics + } + + // Capital that pays on verified outcomes — GainForest + Glow (PL-backed MRV teams). + const verified: LiveMetric[] = [] + if (gf.status === 'fulfilled' && !gf.value.degraded) { + const g = gf.value + if (g.observations > 0) verified.push({ value: compact(g.observations), label: 'species observations' }) + if (g.certifiedOrgs > 0) verified.push({ value: compact(g.certifiedOrgs), label: 'certified orgs' }) + } + if (glow.status === 'fulfilled' && !glow.value.degraded) { + const gl = glow.value + if (gl.activeFarms > 0) verified.push({ value: compact(gl.activeFarms), label: 'active solar farms' }) + if (gl.carbon > 0) verified.push({ value: compact(gl.carbon), label: 'tCO₂ / wk' }) + } + if (verified.length) out['Capital that pays on verified outcomes'] = verified + + return out +} + +export const metadata: Metadata = { + title: 'Impact', + description: + 'How we judge PL R&D: whether the fields we back are speeding up. We name the interventions we run, then read field velocity through five instruments and the inflection points we track.', +} + +export default async function ImpactPage() { + const [liveOutputs, marketSignals] = await Promise.all([fetchLiveOutputs(), resolveAllSignals()]) + + // Merge any OpenAlex CSV readings (idea vintage + talent entry) into the static + // instrument records, per focus area. Parsed at build time; absent CSVs are a + // no-op, leaving the documented `unwired` records in place. + const openAlex = loadAllOpenAlex() + const recordsByArea = Object.fromEntries( + FOCUS_AREAS.map((fa) => [fa.key, withOpenAlex(instrumentsForArea(fa.key), openAlex[fa.key])]), + ) as Partial> + + // Example idea-vintage series per field, for the methodology modal that explains + // the instrument (shown as small multiples). + const ideaVintageExamples = FOCUS_AREAS.map((fa) => { + const rec = (recordsByArea[fa.key] ?? []).find( + (r) => r.instrument === 'idea_vintage' && r.state === 'reading' && r.series && r.series.length > 1, + ) + return rec ? { label: fa.label, series: rec.series!, scale: rec.seriesScale ?? 'linear' } : null + }).filter((x): x is { label: string; series: NonNullable; scale: 'linear' | 'log' } => !!x) + return ( +
+ {/* Hero */} +
+ +
+

+ PL R&D’s impact on field velocity +

+

+ We back fields we think are ready to move, then check whether they do.{' '} + Field velocity is that rate of change: + how fast talent enters, capital forms, tool costs fall, and output ships.{' '} + Inflection points are one of the markers + we read — dated, falsifiable shifts an accelerating field should produce. +

+

+ We measure velocity the same way we do the work: as a research program. Whether field + acceleration works is itself the open question, tested across all four focus areas. +

+ + Learn more about our methodology + + + + +
+
+ + {/* Field velocity — grey full-bleed section to set it apart from the rest of the site */} +
+
+

Field velocity

+

+ Pick a focus area. The summary above reads that field’s velocity across the instruments + that apply to it; the inflection points below are the specific markers we track, each with its + live signal. +

+ +
+
+ + {/* Methodology */} +
+

Our methodology

+

+ The method is the meta-research design of how we do field acceleration. We name the + interventions we run, then read field velocity as the result. Same design, every focus area. +

+ +
+
+ ) +} diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 8ee97b26..a1aa6d40 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -28,7 +28,12 @@ const FALLBACK_CARDS = { 'card-blog': { title: 'Blog' }, } -export default async function InsightsPage() { +export default async function InsightsPage({ + searchParams, +}: { + searchParams: Promise<{ area?: string }> +}) { + const { area: initialArea } = await searchParams const page = await fetchPage('insights') const hero = getSection(page, 'hero') const heroTitle = hero?.title || FALLBACK_HERO_TITLE @@ -223,7 +228,7 @@ export default async function InsightsPage() { )} - + diff --git a/src/components/ImpactDashboardV2.tsx b/src/components/ImpactDashboardV2.tsx new file mode 100644 index 00000000..775295e1 --- /dev/null +++ b/src/components/ImpactDashboardV2.tsx @@ -0,0 +1,1124 @@ +'use client' + +// Impact Dashboard — field velocity. Horizontal, sticky focus-area tabs sit +// above a wide "field velocity" box (the five velocity instruments, each with a +// direction indicator, opening a modal) and the inflection points we track, +// laid out as four cards in two rows with their live signals. The inflection +// cards mirror the PR #29 design; shared primitives are imported, never forked. + +import { useEffect, useMemo, useState } from 'react' +import { + ROLE_META, + PL_ROLE_ORDER, + HOW_TO_READ, + TEAM_LINKS, + resolutionFor, + inflectionLabel, + isConcerningMarker, + inflectionSlug, + type PLRole, +} from '@/lib/inflection-points' +import { + FOCUS_AREAS, + INFLECTION_POINTS, + FIELD_COLOR, + FIELD_INK, + HAND_COLOR, + LIVE_COLOR, + type FocusAreaKey, + type InflectionPoint, +} from '@/lib/field-velocity' +import { + instrumentsForArea, + INSTRUMENT_BY_ID, + DIRECTION_META, + type InstrumentId, + type InstrumentRecord, + type Direction, +} from '@/lib/velocity-instruments' +import { AreaIcon, type AreaIconType } from '@/components/AreaIcons' +import { Sparkline, GhostChart, type SeriesPoint } from '@/components/VelocitySparkline' +import type { MarketSignal } from '@/lib/market-signals' + +/** Live output metrics for a point, keyed by the point's title. Fetched server-side. */ +export type LiveMetric = { value: string; label: string } +export type LiveOutputs = Record +export type MarketSignals = Record + +const PLATFORM_LABEL: Record<'polymarket' | 'kalshi' | 'metaculus', string> = { + polymarket: 'Polymarket', + kalshi: 'Kalshi', + metaculus: 'Metaculus', +} + +const FA_ICON: Record = { + 'digital-human-rights': 'shield', + 'economies-governance': 'hexagon', + 'ai-robotics': 'neural', + neurotech: 'brain', +} + +export default function ImpactDashboardV2({ + liveOutputs = {}, + marketSignals = {}, + recordsByArea, +}: { + liveOutputs?: LiveOutputs + marketSignals?: MarketSignals + /** Instrument records per focus area, precomputed server-side (static records + * merged with any OpenAlex CSV readings). Falls back to the static set. */ + recordsByArea?: Partial> +}) { + const [filter, setFilter] = useState('digital-human-rights') + const [active, setActive] = useState(null) + const [howToOpen, setHowToOpen] = useState(false) + const [velocityOpen, setVelocityOpen] = useState(false) + const [defInstrument, setDefInstrument] = useState(null) + + const visible = useMemo(() => INFLECTION_POINTS.filter((p) => p.area === filter), [filter]) + const records = recordsByArea?.[filter] ?? instrumentsForArea(filter) + // Live forecast markets mapped to this field's markers — surfaced on the + // Markets instrument (the same crowd forecasts shown per inflection point). + const fieldMarkets = useMemo( + () => + visible + .map((p) => marketSignals[p.title]) + .filter((s): s is MarketSignal => !!s && s.match !== 'gap' && (s.prob != null || !!s.readout)), + [visible, marketSignals], + ) + + return ( + <> +
+ {/* Vertical tabs (PR #29 layout), sticky so they + the "How to read this" + affordance stay visible while scrolling the field. */} +
+
+ {FOCUS_AREAS.map((fa) => ( + p.area === fa.key).length} + forthcoming={fa.forthcoming} + icon={FA_ICON[fa.key]} + active={filter === fa.key} + onClick={() => setFilter(fa.key)} + /> + ))} +
+ +
+ + {/* Content: field velocity box + inflection points */} +
+ {/* Field velocity — label outside the box; the box previews the five + instruments and opens a modal. */} +
+ + Field velocity + + · Is the field speeding up? +
+ setVelocityOpen(true)} /> + + {/* Inflection points — four cards in two rows, with live signals. */} +
+ Inflection points we’re tracking +
+ {visible.length > 0 && } + {visible.length > 0 ? ( +
+ {visible.map((p) => ( + setActive(p)} + /> + ))} +
+ ) : ( + + )} +
+
+ + {active && ( + setActive(null)} + /> + )} + {velocityOpen && ( + setVelocityOpen(false)} + onOpenDef={setDefInstrument} + /> + )} + {defInstrument && ( + setDefInstrument(null)} /> + )} + {howToOpen && setHowToOpen(false)} />} + + ) +} + +// ── Focus-area tab (vertical, PR #29 layout) ───────────────────────────────── +function Tab({ + label, + count, + forthcoming = false, + active, + icon, + onClick, +}: { + label: string + count: number + forthcoming?: boolean + active: boolean + icon?: AreaIconType + onClick: () => void +}) { + return ( + + ) +} + +// ── Field velocity box (wide) + its modal ───────────────────────────────────── + +function DirectionChip({ direction, size = 'sm' }: { direction: Direction; size?: 'sm' | 'lg' }) { + const meta = DIRECTION_META[direction] + return ( + + {meta.glyph} + {meta.label} + + ) +} + +/** Show the date portion of an ISO string (the full value stays in provenance). */ +function shortDate(s?: string): string | undefined { + return s ? s.slice(0, 10) : s +} + +/** Review status line. Reads "not yet reviewed" until a review date lands. */ +function reviewedLabel(asOf?: string): string { + return asOf ? `reviewed ${asOf}` : 'not yet reviewed' +} + +function marketReadout(s: MarketSignal): string { + return s.readout ?? (s.prob != null ? `${Math.round(s.prob * 100)}%` : '—') +} + +function InstrumentCell({ record, markets }: { record: InstrumentRecord; markets?: MarketSignal[] }) { + const inst = INSTRUMENT_BY_ID[record.instrument] + // Markets instrument with live mapped forecasts overrides the static state. + if (record.instrument === 'markets' && markets && markets.length) { + return ( +
+ {inst.label} +
+ {markets.slice(0, 2).map((s, i) => ( + + {marketReadout(s)} + {s.platform ? PLATFORM_LABEL[s.platform] : 'market'} + + ))} + {markets.length > 2 && ( + +{markets.length - 2} more + )} +
+
+ ) + } + return ( +
+ {inst.label} + {record.state === 'reading' && ( + <> + {record.series && record.series.length > 1 && ( + (p as SeriesPoint).lo != null)} + axis + unit={record.instrument === 'idea_vintage' ? 'y' : ''} + /> + )} + {record.value} + + {record.direction && } + {record.asOf && as of {shortDate(record.asOf)}} + + + )} + {record.state === 'unwired' && ( + <> + + not yet wired + + )} + {record.state === 'not_applicable' && ( + not applicable to this field + )} +
+ ) +} + +function FieldVelocityBox({ + records, + markets, + onOpen, +}: { + records: InstrumentRecord[] + markets?: MarketSignal[] + onOpen: () => void +}) { + return ( + + ) +} + +function SourceLinks({ sources }: { sources: { label: string; url: string }[] }) { + return ( +
+ {sources.map((s) => ( + + {s.label} + + + + + ))} +
+ ) +} + +function MarketsPanel({ markets }: { markets: MarketSignal[] }) { + return ( +
+
+ {markets.map((s, i) => ( + 0} /> + ))} +
+

+ External forecast markets mapped to this field’s markers. A term structure across horizons + is not yet aggregated. Read with care: market moves partly reflect our own attention work. +

+
+ ) +} + +function VelocityModal({ + area, + records, + markets, + onClose, + onOpenDef, +}: { + area: FocusAreaKey + records: InstrumentRecord[] + markets?: MarketSignal[] + onClose: () => void + onOpenDef: (id: InstrumentId) => void +}) { + useModalChrome(onClose) + const fa = FOCUS_AREAS.find((f) => f.key === area)! + return ( +
+
e.stopPropagation()}> + + +
+
+ + + + {fa.label} +
+
+ Field velocity +
+

+ The rate the field is moving +

+

+ The five instruments we read velocity with. Where a reading is live, it carries a date and a + source. Where it is not, we name the metric we intend to use and what is blocking it. Where an + instrument does not fit this field, we say so. +

+ +
+ {records.map((r) => { + const inst = INSTRUMENT_BY_ID[r.instrument] + const liveMarkets = r.instrument === 'markets' && markets ? markets : [] + const isLiveMarkets = liveMarkets.length > 0 + return ( +
+
+ + {isLiveMarkets && ( + + + + + + {liveMarkets.length} live market{liveMarkets.length === 1 ? '' : 's'} + + )} + {!isLiveMarkets && r.state === 'reading' && r.direction && ( + + + + )} + {!isLiveMarkets && r.state === 'unwired' && ( + not yet wired + )} + {!isLiveMarkets && r.state === 'not_applicable' && ( + not applicable to this field + )} +
+ + {isLiveMarkets && } + + {!isLiveMarkets && r.state === 'reading' && ( +
+
+ {r.series && r.series.length > 1 && ( +
+ (p as SeriesPoint).lo != null)} + width={200} + height={56} + axis + unit={r.instrument === 'idea_vintage' ? 'y' : ''} + /> +
+ )} + {r.series2 && r.series2.length > 1 && ( +
+ +
{r.series2Label ?? 'normalizer'}
+
+ )} +
+
{r.value}
+ {r.metric &&
{r.metric}
} + {r.trend &&
{r.trend}
} +
+ {r.window && window {r.window}} + {r.asOf && as of {shortDate(r.asOf)}} +
+
+
+ {r.provenance && (r.provenance.query || r.provenance.generated) && ( +

+ {r.provenance.query && ( + <> + Keyword cohort (title + abstract): {r.provenance.query} + {r.provenance.generated ? ' · ' : ''} + + )} + {r.provenance.generated && <>retrieved {shortDate(r.provenance.generated)}} +

+ )} + {r.sources && } +
+ )} + + {!isLiveMarkets && r.state === 'unwired' && ( +
+
+ +
+
+

Intended metric: {r.candidateMetric}

+

Blocked by: {r.blocker}

+
+
+ )} + + {!isLiveMarkets && r.state === 'not_applicable' && ( +

{r.reason}

+ )} +
+ ) + })} +
+
+
+
+ ) +} + +function InstrumentDefinitionModal({ id, onClose }: { id: InstrumentId; onClose: () => void }) { + useModalChrome(onClose) + const inst = INSTRUMENT_BY_ID[id] + const researchSide = id === 'idea_vintage' || id === 'revealed_commitments' + return ( +
+
e.stopPropagation()}> + +
+
+ Field velocity +
+

{inst.label}

+
{inst.subtitle}
+

{inst.description}

+ {researchSide && ( +

+ This reads the research side of the field. It does not observe invention directly, and the + two can decouple. +

+ )} +
+
+
+ ) +} + +// ── Inflection cards (PR #29 design) ────────────────────────────────────────── + +// Tone per derived label. Colors are chosen to stay legible on both the light +// card and the dark-mode surface. +function labelTone(label: string): { color: string; bg: string } { + const color = + label === 'reached — field moved' + ? '#16a34a' + : label === 'reached — no lift' || label === 'missed' + ? '#dc2626' + : label === 'missed — field moved another way' + ? '#d0894b' + : label === 'reached — lift unclear' + ? '#6b7fb3' + : '#6b7280' // pending, retired — superseded + return { color, bg: `color-mix(in srgb, ${color} 14%, transparent)` } +} + +function ResolutionChip({ point }: { point: InflectionPoint }) { + const label = inflectionLabel(point) + const tone = labelTone(label) + return ( + + {label} + + ) +} + +/** predictedBy / falsifiesIf with the gap rendered visibly when null. */ +function ResolutionMeta({ point, stacked = false }: { point: InflectionPoint; stacked?: boolean }) { + const r = resolutionFor(point) + return ( +
+ + Predicted by: {r.predictedBy ?? 'date not yet set'} + + {stacked ?
: ·} + + Falsifies if: {r.falsifiesIf ?? 'condition not yet set'} + +
+ ) +} + +/** Misses affordance: present even at zero count, before the first miss lands. */ +function MissesLedger({ points }: { points: InflectionPoint[] }) { + const flagged = points.filter(isConcerningMarker) + return ( +
+
+ {flagged.length} + markers missed, retired, or reached without lift +
+ {flagged.length > 0 ? ( + + ) : ( +

+ None yet. Misses and retirements will be listed here as they happen. +

+ )} +
+ ) +} + +function RoleChips({ roles }: { roles: PLRole[] }) { + const ordered = PL_ROLE_ORDER.filter((r) => roles.includes(r)) + return ( + + {ordered.map((r) => ( + + + {ROLE_META[r].label} + + + {ROLE_META[r].label} — {ROLE_META[r].description} + + + ))} + + ) +} + +function InflectionCard({ + point, + metrics, + signal, + onOpen, +}: { + point: InflectionPoint + metrics?: LiveMetric[] + signal?: MarketSignal + onOpen: () => void +}) { + const fa = FOCUS_AREAS.find((f) => f.key === point.area)! + const hasLiveSignal = !!( + point.liveEvidence?.length || + (metrics && metrics.length) || + (signal && signal.match !== 'gap') + ) + + const resolution = resolutionFor(point) + return ( + + ) +} + +function formatUSD(n: number): string { + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M` + if (n >= 1_000) return `$${Math.round(n / 1_000)}k` + return `$${Math.round(n)}` +} + +function CrowdForecast({ signal, divider = false }: { signal: MarketSignal; divider?: boolean }) { + const pct = signal.prob != null ? Math.round(signal.prob * 100) : null + return ( +
+
+ Crowd forecast + {signal.platform && ( + + {PLATFORM_LABEL[signal.platform]} + {signal.viaFallback ? ' (fallback)' : ''} + + )} + {signal.volume != null && ( + + {formatUSD(signal.volume)} at stake + + )} + + {signal.readout ?? (pct != null ? `${pct}%` : '—')} + +
+ {signal.url && ( + + {signal.question} + + )} +

+ {signal.note} An independent read on whether the field is moving — not a settled outcome. +

+
+ ) +} + +function useModalChrome(onClose: () => void) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = prev + } + }, [onClose]) +} + +function InflectionModal({ + point, + metrics, + signal, + onClose, +}: { + point: InflectionPoint + metrics?: LiveMetric[] + signal?: MarketSignal + onClose: () => void +}) { + const fa = FOCUS_AREAS.find((f) => f.key === point.area)! + useModalChrome(onClose) + + return ( +
+
e.stopPropagation()}> + + +
+
+ + + + {fa.label} + · + {point.opportunitySpace} +
+ +
+
+ The field +
+

{point.title}

+
+
+
Outcome
+

{point.signal}

+
+
+
Impact
+

{point.cascade}

+
+
+ {/* Resolution: outcome × mattered (never inferred from one another). */} +
+
+ + {reviewedLabel(resolutionFor(point).asOf)} +
+ + {resolutionFor(point).matteredEvidence && ( +

+ Why it mattered:{' '} + {resolutionFor(point).matteredEvidence} +

+ )} + {resolutionFor(point).retiredReason && ( +

+ Retired because:{' '} + {resolutionFor(point).retiredReason} +

+ )} +
+
+ +
+
+ Our hand +
+
PL R&D interventions
+ +
+
In practice
+

+

+
+
+ +
+
+ + + + + Live signal +
+ {(point.liveEvidence?.length || (metrics && metrics.length) || (signal && signal.match !== 'gap')) && ( +
+ {point.liveEvidence?.map((ev, i) => { + const external = /^https?:\/\//.test(ev.href) + return ( + 0 ? 'mt-3 border-t border-gray-100 pt-3' : ''}`} + > +
+ {ev.label} + + + +
+ {i === 0 && metrics && metrics.length > 0 && ( +
+ {metrics.map((m) => ( + + {m.value} + {m.label} + + ))} +
+ )} +

{ev.note}

+
+ ) + })} + + {!point.liveEvidence?.length && metrics && metrics.length > 0 && ( +
+ {metrics.map((m) => ( + + {m.value} + {m.label} + + ))} +
+ )} + + {signal && signal.match !== 'gap' && ( + + )} +
+ )} + + {signal && signal.match === 'gap' && ( +
+
Crowd forecast
+

{signal.note}

+
+ )} + + + See the latest {fa.label} insights + + + + +
+
+
+
+ ) +} + +function EmptyState({ filter }: { filter: FocusAreaKey }) { + const fa = FOCUS_AREAS.find((f) => f.key === filter) + return ( +
+

+ {fa?.label ?? 'This focus area'} is forthcoming. +

+

+ Its inflection points will appear here as the focus area comes online. +

+
+ ) +} + +// ── Team-name linkify (reuses the shared TEAM_LINKS map, read-only) ─────────── +const TEAM_LINK_PATTERN = new RegExp( + '(' + + Object.keys(TEAM_LINKS) + .sort((a, b) => b.length - a.length) + .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|') + + ')', + 'g', +) + +function Linkify({ text }: { text: string }) { + const parts = text.split(TEAM_LINK_PATTERN) + return ( + <> + {parts.map((part, i) => + TEAM_LINKS[part] ? ( + + {part} + + ) : ( + {part} + ), + )} + + ) +} + +// ── How to read this ────────────────────────────────────────────────────────── +function HowToReadModal({ onClose }: { onClose: () => void }) { + useModalChrome(onClose) + return ( +
+
e.stopPropagation()}> + + +
How to read this
+

{HOW_TO_READ}

+ +
+ + The change in the world: did it happen (outcome) and did it matter (impact). Moves with or without us. + + + Our contribution: the PL instruments on the critical path. The axis we control with our partners. + +
+ + + + + + +

+ Live signal — real-world evidence for and against, refreshed from the field. Never a settled outcome. +

+
+
+
+
+ ) +} + +function LegendRow({ + color, + ink, + label, + children, +}: { + color: string + ink: string + label: string + children: React.ReactNode +}) { + return ( +
+ +

+ {label} — {children} +

+
+ ) +} diff --git a/src/components/InsightsExplorer.tsx b/src/components/InsightsExplorer.tsx index 656eaaf8..3c308067 100644 --- a/src/components/InsightsExplorer.tsx +++ b/src/components/InsightsExplorer.tsx @@ -49,12 +49,16 @@ const DISPLAY_LIMIT = 12 export default function InsightsExplorer({ sections, areas, + initialArea, }: { sections: InsightSection[] areas: AreaDef[] + initialArea?: string }) { const [type, setType] = useState(ALL) - const [area, setArea] = useState(ALL) + const [area, setArea] = useState( + initialArea && areas.some((a) => a.slug === initialArea) ? initialArea : ALL, + ) const matchArea = (tile: InsightTile, a: string) => a === ALL || tile.areas.includes(a) diff --git a/src/components/MeasuringQuestionsV2.tsx b/src/components/MeasuringQuestionsV2.tsx new file mode 100644 index 00000000..d7d58a10 --- /dev/null +++ b/src/components/MeasuringQuestionsV2.tsx @@ -0,0 +1,314 @@ +'use client' + +// Methodology section. Two blocks joined by a downward divider that reads as +// "these interventions drive the below": (1) the PL R&D interventions (the +// toolkit), and (2) observed field velocity, the five instruments we read the +// result with. Both the toolkit tools and the velocity instruments open modals. + +import { useEffect, useState } from 'react' +import { HAND_COLOR, FIELD_COLOR, TOOLKIT_V2, type ToolkitEntry } from '@/lib/field-velocity' +import { VELOCITY_INSTRUMENTS, INFLECTION_EXPLAINER } from '@/lib/velocity-instruments' +import { Sparkline, type SeriesPoint } from '@/components/VelocitySparkline' + +type DefEntry = { id: string; label: string; subtitle: string; description: string } +export type IdeaVintageExample = { label: string; series: SeriesPoint[]; scale: 'linear' | 'log' } +type Modal = + | { kind: 'tool'; entry: ToolkitEntry } + | { kind: 'measure'; entry: DefEntry } + +export default function MeasuringQuestionsV2({ + ideaVintageExamples = [], +}: { + ideaVintageExamples?: IdeaVintageExample[] +}) { + const [modal, setModal] = useState(null) + + return ( +
+ {/* Block 1 — the interventions (our toolkit) */} +
+
+
+
+ Our hand +
+

PL R&D interventions

+

+ A fixed toolkit we bring to every field. Pick the ones a field is missing, then push. +

+

+ We don’t claim these interventions directly cause a field to accelerate — + attribution at the field level isn’t cleanly identifiable. For now we name what we run + and watch whether the field moves; making that link clearer is work we intend to do, and to + publish here later. +

+
+ +
+ {TOOLKIT_V2.map((t) => ( + + ))} +
+
+
+ + {/* Downward divider — interventions drive the observed velocity below. */} +
+
+ + + + + +
+ + {/* Block 2 — observed field velocity, the result we watch */} +
+
+
+
+ The field +
+

Observed velocity

+

+ The interventions are the input. Field velocity, the rate a field is moving, is what tells + us whether they landed. We read it through five instruments. +

+

+ Not every instrument fits every field. A field with no single gating unit cost has no + performance curve to read, and a field no forecast market has priced has no market signal. + We show the instruments that apply and name the ones that do not. +

+
+ +
+ {[...VELOCITY_INSTRUMENTS, INFLECTION_EXPLAINER].map((m) => ( + + ))} +
+

+ These instruments read the research and capital sides of a field. They do not observe + invention directly — prototypes, designs, datasets, and negative results largely lack + identifiers to count. Closing that gap is itself part of the work. +

+
+
+ + {modal && ( + setModal(null)} /> + )} +
+ ) +} + +// Abbreviated named-vs-mattered framing for the inflection-points modal. +function InflectionQuadrant() { + const quad = (title: string, body: string, tone: string) => ( +
+
{title}
+

{body}

+
+ ) + return ( +
+
What got named vs. what actually mattered
+

+ Two independent axes. Named in advance: was this the + milestone the field, its funders, and its press had publicly designated as the thing to watch?{' '} + Mattered: did crossing it measurably change the + field’s trajectory — cost curves, entry rates, capital formation, deployment? +

+ +
+ {quad('Named · mattered', 'The good case. Prediction and consequence aligned; a public call was gradeable and correct.', '#16a34a')} + {quad('Named · didn’t matter', 'Trap 1: the hit that doesn’t lift. The sensor resolves true, the field doesn’t move. Pair every marker with a “did it matter” test.', '#dc2626')} + {quad('Not named · mattered', 'Trap 2: acceleration through a door nobody was watching. Only a continuous velocity basket catches it.', '#d0894b')} + {quad('Not named · didn’t matter', 'Noise. Not interesting, but where most events live — which is why precision matters.', '#6b7280')} +
+ +
+

Named & mattered: the $1,000 genome (2014); AlphaFold 2 at CASP14 (2020) — a named unit-cost or benchmark that was also the field’s rate-limiter.

+

Named, didn’t matter: Watson wins Jeopardy! (2011); Deep Blue (1997) — staged demos that didn’t bend the trajectory.

+

Unnamed, mattered: AlexNet (2012), transformers (2017), HTS fusion magnets at 20 T (2021) — the largest recent accelerations, none on a roadmap.

+
+ +
+
What it implies for the dashboard
+

Six of eleven canonical cases sit in the two trap quadrants. So: named markers can’t be the only instrument (the velocity basket catches unnamed acceleration); prefer cost thresholds and capability curves over demos; and write a “did it matter” test next to every marker.

+
+
+ ) +} + +function IdeaVintageExamples({ examples }: { examples: IdeaVintageExample[] }) { + if (!examples.length) return null + return ( +
+
What it looks like today
+

+ Median age of the references cited in new work, in years, per focus area on a shared axis. The + solid line is reliable years, the shaded band is the 95% interval, and the dashed tail is the most + recent years, still under-indexed. A lower line means the field is building on fresher ideas. +

+
+ {examples.map((e) => ( +
+
{e.label}
+ +
+ ))} +
+ +
+
How we build this from OpenAlex
+

+ The source is OpenAlex, an open, CC0 catalogue of more than 250 million works and the reference + lists that connect them. For each year we take a random sample of works whose title or abstract + matches a fixed keyword query for the field, and we store that query with every series. For each + work we read the publication year of every reference it cites, pool them, and take the median + age, which is the work’s year minus the reference’s year. The shaded band is a + bootstrap 95% interval on that median. Direction compares the mean of the earliest three reliable + years with the most recent three, and calls a trend only once the shift clears a flat band, so + sampling noise stays flat. Treat the number as directional. +

+
+ +
+
Reading OpenAlex without its artefacts
+

+ OpenAlex is a moving target: it grows and re-labels in large batches, and it lags on recent + work. Three habits keep those artefacts out of the reading. +

+
    +
  • + Size is not velocity. We read an age, not a + count, so a bulk addition to the catalogue (one recent backfill added on the order of 190 + million works) cannot show up as acceleration. Any instrument that does count is normalized + against total corpus growth, as a share per 100,000 works, never a raw total. +
  • +
  • + Labels drift; keywords hold. We define each + field with a fixed title-and-abstract keyword query rather than OpenAlex’s own topic + labels, so a large re-classification (like the 2026 backlog clearance) cannot look like a field + taking off. Each series stores its query and the date it was pulled, and every refresh is a new + versioned pull. +
  • +
  • + Recent years lag. The last two years are + under-indexed and always look like a slowdown, so we mark them dashed, drop them from the + direction, and compare matching year windows. +
  • +
+
+
+ ) +} + +function InfoModal({ + modal, + ideaVintageExamples, + onClose, +}: { + modal: Modal + ideaVintageExamples: IdeaVintageExample[] + onClose: () => void +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = prev + } + }, [onClose]) + + const isTool = modal.kind === 'tool' + const eyebrow = isTool ? 'Intervention' : 'Field velocity' + const eyebrowColor = isTool ? HAND_COLOR : FIELD_COLOR + const title = isTool ? modal.entry.title : modal.entry.label + const subtitle = modal.entry.subtitle + const proposed = isTool ? modal.entry.proposed : false + const description = modal.entry.description + const researchSide = + modal.kind === 'measure' && + (modal.entry.id === 'idea_vintage' || modal.entry.id === 'revealed_commitments') + const isIdeaVintage = modal.kind === 'measure' && modal.entry.id === 'idea_vintage' + const isInflection = modal.kind === 'measure' && modal.entry.id === 'inflection_points' + + return ( +
+
e.stopPropagation()}> + + +
+
+ + {eyebrow} + + {proposed && ( + + Proposed — our wildcard + + )} +
+

{title}

+ {subtitle &&
{subtitle}
} +

{description}

+ {researchSide && ( +

+ This reads the research side of the field. It does not observe invention directly, and the + two can decouple. +

+ )} + {isIdeaVintage && } + {isInflection && } +
+
+
+ ) +} diff --git a/src/components/PLRadar.tsx b/src/components/PLRadar.tsx index adf8cf02..1354045a 100644 --- a/src/components/PLRadar.tsx +++ b/src/components/PLRadar.tsx @@ -34,6 +34,9 @@ export type RadarItem = { const STORAGE_KEY = 'plrd-radar-collapsed' +/** How long each story slide dwells before auto-advancing (ms). */ +const SLIDE_MS = 7000 + const AREA_GRADIENT: Record = { 'digital-human-rights': { from: '#0b1f4d', via: '#1e3a8a', to: '#3966FE', dot: '#3966FE' }, 'economies-governance': { from: '#0a3b2e', via: '#0f6b4c', to: '#12bfdf', dot: '#12bfdf' }, @@ -82,6 +85,8 @@ function CoverImage({ src, alt }: { src: string; alt: string }) { export default function PLRadar({ edition, items }: { edition: string; items: RadarItem[] }) { const [open, setOpen] = useState(true) const [i, setI] = useState(0) + const [progress, setProgress] = useState(0) + const [paused, setPaused] = useState(false) const sectionRef = useRef(null) const touchX = useRef(null) @@ -100,10 +105,40 @@ export default function PLRadar({ edition, items }: { edition: string; items: Ra const N = items.length + 1 // + "all caught up" slide const go = useCallback((n: number) => setI((prev) => Math.max(0, Math.min(N - 1, n))), [N]) + const atEnd = i === items.length + + // Story-style auto-advance: fill the current slide's bar over SLIDE_MS, then + // step to the next slide. Pauses while collapsed, held/hovered, or when the + // tab is hidden, and halts on the final "all caught up" slide. + useEffect(() => { + setProgress(0) + if (!open || paused || atEnd) return + let raf = 0 + let start: number | null = null + const tick = (now: number) => { + if (start === null) start = now + const p = Math.min(1, (now - start) / SLIDE_MS) + setProgress(p) + if (p >= 1) go(i + 1) + else raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [i, open, paused, atEnd, go]) + + // Pause auto-advance when the tab loses focus (browsers throttle rAF anyway). + useEffect(() => { + const onVis = () => setPaused(document.hidden) + document.addEventListener('visibilitychange', onVis) + return () => document.removeEventListener('visibilitychange', onVis) + }, []) + const onTouchStart = (e: React.TouchEvent) => { touchX.current = e.touches[0].clientX + setPaused(true) // press-and-hold pauses, like stories } const onTouchEnd = (e: React.TouchEvent) => { + setPaused(false) if (touchX.current === null) return const dx = e.changedTouches[0].clientX - touchX.current if (dx < -40) go(i + 1) @@ -130,8 +165,6 @@ export default function PLRadar({ edition, items }: { edition: string; items: Ra window.open(intent, '_blank', 'noopener,noreferrer') } - const atEnd = i === items.length - return (
{/* progress segments — own band above the content so they never overlap it */}
- {Array.from({ length: N }).map((_, idx) => ( -
-
-
- ))} + {Array.from({ length: N }).map((_, idx) => { + // Past slides are full; the current slide fills as it plays; future slides are empty. + const fill = idx < i ? 1 : idx === i ? (atEnd ? 1 : progress) : 0 + return ( +
+
+
+ ) + })}
setPaused(true)} + onMouseLeave={() => setPaused(false)} > {/* content slides */} {items.map((item, idx) => { diff --git a/src/components/VelocitySparkline.tsx b/src/components/VelocitySparkline.tsx new file mode 100644 index 00000000..3f9823ff --- /dev/null +++ b/src/components/VelocitySparkline.tsx @@ -0,0 +1,139 @@ +// Shared inline-SVG sparkline + ghost chart for the field-velocity instruments. +// Pure SVG (no deps). Supports a log axis, a confidence band, a dashed tail for +// unreliable points, an optional secondary normalizer line, and an optional +// labelled vertical axis (min/max of the series, so the reader can see what the +// curve indexes to). + +export type SeriesPoint = { x: number | string; y: number; lo?: number; hi?: number; reliable?: boolean } + +function fmt(v: number, unit: string): string { + const n = Math.abs(v) >= 1000 ? `${(v / 1000).toFixed(1)}k` : Number.isInteger(v) ? `${v}` : v.toFixed(1) + return unit ? `${n}${unit}` : n +} + +export function Sparkline({ + series, + series2, + scale = 'linear', + width = 116, + height = 34, + band = false, + axis = false, + unit = '', +}: { + series: SeriesPoint[] + series2?: { x: number | string; y: number }[] + scale?: 'linear' | 'log' + width?: number + height?: number + band?: boolean + axis?: boolean + unit?: string +}) { + if (!series.length) return null + const tf = (v: number) => (scale === 'log' ? Math.log10(Math.max(v, 1e-6)) : v) + const xOf = (p: { x: number | string }, i: number) => (typeof p.x === 'number' ? p.x : i) + + const padL = axis ? 30 : 2 + const padR = 2 + + const xsAll = [ + ...series.map((p, i) => xOf(p, i)), + ...(series2 ? series2.map((p, i) => xOf(p, i)) : []), + ] + const minX = Math.min(...xsAll) + const maxX = Math.max(...xsAll) + const spanX = maxX - minX || 1 + + const rawYs: number[] = [] + series.forEach((p) => { + rawYs.push(p.y) + if (band && p.lo != null) rawYs.push(p.lo) + if (band && p.hi != null) rawYs.push(p.hi) + }) + if (series2) series2.forEach((p) => rawYs.push(p.y)) + const minRaw = Math.min(...rawYs) + const maxRaw = Math.max(...rawYs) + const minY = tf(minRaw) + const maxY = tf(maxRaw) + const spanY = maxY - minY || 1 + + const px = (x: number) => padL + ((x - minX) / spanX) * (width - padL - padR) + const py = (v: number) => height - 3 - ((tf(v) - minY) / spanY) * (height - 6) + const pt = (p: SeriesPoint, i: number) => `${px(xOf(p, i)).toFixed(1)},${py(p.y).toFixed(1)}` + + const hasReliableFlags = series.some((p) => p.reliable === false) + const reliablePts = hasReliableFlags ? series.filter((p) => p.reliable !== false) : series + const lastReliableIdx = hasReliableFlags + ? series.reduce((acc, p, i) => (p.reliable !== false ? i : acc), 0) + : series.length - 1 + const tailPts = hasReliableFlags ? series.slice(lastReliableIdx) : [] + + let bandPath = '' + if (band && series.every((p) => p.lo != null && p.hi != null)) { + const top = series.map((p, i) => `${px(xOf(p, i)).toFixed(1)},${py(p.hi as number).toFixed(1)}`) + const bot = [...series].reverse().map((p, i) => { + const idx = series.length - 1 - i + return `${px(xOf(p, idx)).toFixed(1)},${py(p.lo as number).toFixed(1)}` + }) + bandPath = `${top.join(' ')} ${bot.join(' ')}` + } + + const last = series[series.length - 1] + return ( + + {axis && ( + + + {fmt(maxRaw, unit)} + {fmt(minRaw, unit)} + + )} + {bandPath && } + {series2 && ( + `${px(xOf(p, i)).toFixed(1)},${py(p.y).toFixed(1)}`).join(' ')} + fill="none" + stroke="#9ca3af" + strokeWidth={1.25} + strokeDasharray="3 3" + /> + )} + pt(p, series.indexOf(p))).join(' ')} + fill="none" + stroke="var(--impact-field)" + strokeWidth={1.75} + strokeLinejoin="round" + strokeLinecap="round" + /> + {tailPts.length > 1 && ( + pt(p, series.indexOf(p))).join(' ')} + fill="none" + stroke="var(--impact-field)" + strokeWidth={1.75} + strokeDasharray="2 2" + opacity={0.45} + /> + )} + + + ) +} + +export function GhostChart({ width = 116, height = 34 }: { width?: number; height?: number }) { + return ( + + ) +} diff --git a/src/data/velocity/README.md b/src/data/velocity/README.md new file mode 100644 index 00000000..6db1f934 --- /dev/null +++ b/src/data/velocity/README.md @@ -0,0 +1,57 @@ +# Velocity CSVs (OpenAlex) + +Drop one CSV per focus area here to light up the **idea vintage** instrument (and +the talent-entry component of **revealed commitments**) on `/impact`. Parsing +happens at build time in `src/lib/velocity-openalex.ts`; no browser code changes +are needed. If a file is absent, the instrument stays the documented `unwired` +record. + +Expected filenames (focus-area slugs): + +- `digital-human-rights.csv` +- `economies-governance.csv` +- `ai-robotics.csv` +- `neurotech.csv` + +Generated by `scripts/velocity/field_velocity_openalex.py`. Do **not** hand-write +or commit synthetic CSVs — an absent file is the correct empty state. + +## Format + +Leading `#` lines are provenance and are retained. At minimum include the frozen +query string and the generated timestamp; the ingestion surfaces both in the +instrument modal, because a series is only comparable against itself if the query +string is identical. + +``` +# generated: 2026-08-03T12:00:00Z +# source: OpenAlex (https://openalex.org, CC0) +# query: content addressing|peer-to-peer network|censorship resistance|decentralized storage +# from_year: 2005 +# reliable_cutoff_year: 2023 +# sample: refs_per_work=25; max_works_per_year=2000 +year,field_works,all_works,corpus_share_per_100k,first_time_authors,idea_vintage_median_years,vintage_ci_lo,vintage_ci_hi,n_refs_sampled,reliable +2005,1200,3100000,38.7,410,9.8,9.1,10.5,21000,1 +... +``` + +- `reliable = 0` marks recent years undercounted by OpenAlex indexing lag. They + render as a dashed segment and are **excluded** from the direction calculation. +- `idea_vintage_median_years` drives the primary series; `vintage_ci_lo/hi` draw + the confidence band. Rows with a null median are skipped. +- Direction for idea vintage is **inverted**: a falling median reference age means + the field is building on fresher ideas, i.e. accelerating + (`scripts/velocity/vintage-direction.mjs`, covered by `npm test`). + +## Frozen query strings + +| Focus area | `--search` | `--from-year` | +| --- | --- | --- | +| Digital Human Rights | `content addressing\|peer-to-peer network\|censorship resistance\|decentralized storage` | 2005 | +| Programmable Economies & Governance | `mechanism design\|public goods funding\|quadratic funding\|digital public infrastructure` | 2005 | +| AI & Robotics | `decentralized training\|federated learning\|multi-agent coordination\|embodied AI` | 2005 | +| Neurotechnology | `brain-computer interface\|connectome\|neural implant\|whole brain emulation` | 2000 | + +These are starting definitions, not settled ones. Whatever string produced a CSV +is recorded in that CSV's provenance and rendered in the modal, so a later +refinement reads as a definitional change rather than a phantom inflection. diff --git a/src/data/velocity/ai-robotics.csv b/src/data/velocity/ai-robotics.csv new file mode 100644 index 00000000..e4479f5c --- /dev/null +++ b/src/data/velocity/ai-robotics.csv @@ -0,0 +1,29 @@ +# generated: 2026-08-03T17:33:18+00:00 +# source: OpenAlex (https://openalex.org, CC0) +# query: decentralized training|federated learning|multi-agent coordination|embodied AI +# from_year: 2005 +# reliable_cutoff_year: 2024 +# sample: works_per_year=80; max_refs_per_work=50; cluster=title_and_abstract.search; note=first_time_authors intentionally blank (needs per-author history) +year,field_works,all_works,corpus_share_per_100k,first_time_authors,idea_vintage_median_years,vintage_ci_lo,vintage_ci_hi,n_refs_sampled,reliable +2005,457,5467014,8.359,,5,4,5,833,1 +2006,540,5973060,9.041,,6.0,5.0,6.0,666,1 +2007,442,6497140,6.803,,5.0,5.0,6.0,994,1 +2008,594,6945135,8.553,,6.0,6.0,6.0,966,1 +2009,615,7561029,8.134,,7,6,7,859,1 +2010,633,8099731,7.815,,7,6,7,1041,1 +2011,644,8675456,7.423,,7.0,6.0,7.0,1048,1 +2012,622,8966620,6.937,,7.0,6.0,7.0,1002,1 +2013,654,9420404,6.942,,8.0,7.0,8.0,854,1 +2014,673,9971270,6.749,,8,7,8,1155,1 +2015,651,10192661,6.387,,7.0,7.0,8.0,1264,1 +2016,679,10584640,6.415,,6.0,6.0,7.0,1302,1 +2017,724,10449786,6.928,,7,7,8,1131,1 +2018,917,10390067,8.826,,6.0,6.0,7.0,850,1 +2019,1451,10762259,13.482,,5.0,5.0,6.0,1264,1 +2020,2838,11455499,24.774,,3.0,3.0,4.0,1444,1 +2021,5007,10770199,46.489,,4,3,4,1215,1 +2022,7053,10244386,68.847,,4.0,3.0,4.0,1368,1 +2023,10662,10834591,98.407,,3.0,3.0,4.0,1464,1 +2024,14900,10712747,139.087,,4.0,3.0,4.0,1394,0 +2025,27561,14977327,184.018,,3,3,4,891,0 +2026,28680,28500484,100.63,,4.0,4.0,4.0,684,0 diff --git a/src/data/velocity/digital-human-rights.csv b/src/data/velocity/digital-human-rights.csv new file mode 100644 index 00000000..981c23a3 --- /dev/null +++ b/src/data/velocity/digital-human-rights.csv @@ -0,0 +1,29 @@ +# generated: 2026-08-03T17:21:39+00:00 +# source: OpenAlex (https://openalex.org, CC0) +# query: content addressing|peer-to-peer network|censorship resistance|decentralized storage +# from_year: 2005 +# reliable_cutoff_year: 2024 +# sample: works_per_year=80; max_refs_per_work=50; cluster=title_and_abstract.search; note=first_time_authors intentionally blank (needs per-author history) +year,field_works,all_works,corpus_share_per_100k,first_time_authors,idea_vintage_median_years,vintage_ci_lo,vintage_ci_hi,n_refs_sampled,reliable +2005,6043,5467014,110.536,,5.0,5.0,6.0,1104,1 +2006,7138,5973060,119.503,,4,4,5,1089,1 +2007,7615,6497140,117.205,,6.0,6.0,7.0,1056,1 +2008,8430,6945135,121.38,,6.0,5.0,6.0,1144,1 +2009,9495,7561029,125.578,,6.0,6.0,7.0,1154,1 +2010,10318,8099731,127.387,,6,6,7,1109,1 +2011,11065,8675456,127.544,,7,6,7,1447,1 +2012,12030,8966620,134.164,,8.0,7.0,8.0,1454,1 +2013,12568,9420404,133.413,,7.0,6.0,7.0,1496,1 +2014,13582,9971270,136.211,,7.0,6.0,7.0,1452,1 +2015,13962,10192661,136.981,,6.0,6.0,6.0,1376,1 +2016,14957,10584640,141.309,,8.0,7.0,8.0,1354,1 +2017,15743,10449786,150.654,,6,6,7,1301,1 +2018,17701,10390067,170.365,,8,7,8,1489,1 +2019,19818,10762259,184.143,,9,9,10,1231,1 +2020,23025,11455499,200.995,,6.0,6.0,7.0,1420,1 +2021,23442,10770199,217.656,,8,7,8,1719,1 +2022,23368,10244386,228.105,,7,6,7,1895,1 +2023,30552,10834591,281.986,,6,6,6,1451,1 +2024,44977,10712747,419.846,,6.0,6.0,6.0,1414,0 +2025,72474,14977327,483.891,,7.0,7.0,8.0,1602,0 +2026,57805,28500484,202.821,,6,6,7,739,0 diff --git a/src/data/velocity/economies-governance.csv b/src/data/velocity/economies-governance.csv new file mode 100644 index 00000000..2d876aa1 --- /dev/null +++ b/src/data/velocity/economies-governance.csv @@ -0,0 +1,29 @@ +# generated: 2026-08-03T17:28:21+00:00 +# source: OpenAlex (https://openalex.org, CC0) +# query: mechanism design|public goods funding|quadratic funding|digital public infrastructure +# from_year: 2005 +# reliable_cutoff_year: 2024 +# sample: works_per_year=80; max_refs_per_work=50; cluster=title_and_abstract.search; note=first_time_authors intentionally blank (needs per-author history) +year,field_works,all_works,corpus_share_per_100k,first_time_authors,idea_vintage_median_years,vintage_ci_lo,vintage_ci_hi,n_refs_sampled,reliable +2005,15479,5467014,283.134,,6,6,7,885,1 +2006,18013,5973060,301.571,,6,6,6,1259,1 +2007,18852,6497140,290.158,,6,5,6,1359,1 +2008,21182,6945135,304.99,,6.0,6.0,7.0,1248,1 +2009,23134,7561029,305.964,,6,6,7,1423,1 +2010,25666,8099731,316.875,,6.0,6.0,7.0,1218,1 +2011,28299,8675456,326.196,,7.0,6.0,7.0,1484,1 +2012,29379,8966620,327.649,,7.0,7.0,7.0,1400,1 +2013,31919,9420404,338.828,,6,6,7,1367,1 +2014,33755,9971270,338.523,,7.0,7.0,8.0,1556,1 +2015,34382,10192661,337.321,,7,6,7,1573,1 +2016,35701,10584640,337.291,,7,7,8,1605,1 +2017,38209,10449786,365.644,,7,7,8,1591,1 +2018,42761,10390067,411.557,,7.0,6.0,7.0,1714,1 +2019,46663,10762259,433.58,,7.0,7.0,7.0,1710,1 +2020,53437,11455499,466.475,,8,7,8,2085,1 +2021,58490,10770199,543.073,,6,6,7,2153,1 +2022,62868,10244386,613.682,,6.0,6.0,6.0,2202,1 +2023,75427,10834591,696.168,,7.0,7.0,8.0,1930,1 +2024,92772,10712747,865.996,,7.0,7.0,8.0,2126,0 +2025,170276,14977327,1136.892,,6.0,6.0,7.0,1578,0 +2026,176730,28500484,620.095,,7,6,7,1273,0 diff --git a/src/data/velocity/neurotech.csv b/src/data/velocity/neurotech.csv new file mode 100644 index 00000000..7bc997da --- /dev/null +++ b/src/data/velocity/neurotech.csv @@ -0,0 +1,34 @@ +# generated: 2026-08-03T17:41:17+00:00 +# source: OpenAlex (https://openalex.org, CC0) +# query: brain-computer interface|connectome|neural implant|whole brain emulation +# from_year: 2000 +# reliable_cutoff_year: 2024 +# sample: works_per_year=80; max_refs_per_work=50; cluster=title_and_abstract.search; note=first_time_authors intentionally blank (needs per-author history) +year,field_works,all_works,corpus_share_per_100k,first_time_authors,idea_vintage_median_years,vintage_ci_lo,vintage_ci_hi,n_refs_sampled,reliable +2000,294,3809639,7.717,,6.0,5.0,6.0,1366,1 +2001,294,3816596,7.703,,6.0,6.0,7.0,1134,1 +2002,439,4431926,9.905,,7,7,7,1407,1 +2003,490,4721682,10.378,,6,5,6,967,1 +2004,604,5123751,11.788,,6.0,5.0,6.0,1060,1 +2005,810,5467014,14.816,,5,5,6,1309,1 +2006,898,5973060,15.034,,5,5,6,897,1 +2007,1109,6497140,17.069,,7,6,7,1291,1 +2008,1226,6945135,17.653,,6,5,6,1327,1 +2009,1480,7561029,19.574,,6.0,5.0,6.0,1238,1 +2010,1573,8099731,19.42,,6.0,6.0,6.0,1506,1 +2011,1850,8675456,21.325,,6,6,7,1565,1 +2012,2012,8966620,22.439,,6,6,6,1469,1 +2013,2567,9420404,27.249,,6.0,5.0,6.0,1566,1 +2014,2726,9971270,27.339,,6,5,6,1315,1 +2015,2994,10192661,29.374,,7.0,6.0,7.0,1494,1 +2016,3463,10584640,32.717,,6.0,6.0,7.0,1560,1 +2017,3602,10449786,34.47,,7.0,6.0,7.0,1874,1 +2018,4071,10390067,39.182,,8.0,7.0,8.0,1964,1 +2019,4374,10762259,40.642,,7,7,8,1925,1 +2020,5004,11455499,43.682,,7.0,7.0,8.0,2016,1 +2021,5484,10770199,50.918,,8,8,8,2113,1 +2022,5659,10244386,55.24,,9.0,9.0,9.0,2166,1 +2023,6158,10834591,56.836,,9.0,9.0,10.0,2082,1 +2024,7034,10712747,65.66,,8,8,9,1789,0 +2025,9126,14977327,60.932,,7.0,7.0,7.0,1692,0 +2026,7751,28500484,27.196,,8,8,9,1315,0 diff --git a/src/lib/field-velocity.ts b/src/lib/field-velocity.ts new file mode 100644 index 00000000..f296177f --- /dev/null +++ b/src/lib/field-velocity.ts @@ -0,0 +1,129 @@ +// ── Impact Dashboard — the field-velocity data model ────────────────────────── +// +// The page names the interventions PL R&D runs, then reads whether the fields we +// back are speeding up. It imports focus-area metadata, inflection points, and +// the two-axis colors from `@/lib/inflection-points` as the single source of +// truth. +// +// The model, in one line: +// Interventions = a fixed toolkit we push on, per field. +// Field velocity = the rate a field is moving, read through five instruments +// (performance curves, latency compression, idea vintage, +// revealed commitments, markets). +// Inflection points = the specific, dated markers we track, each with its live +// signal. +// +// Where we do not yet have a reading, a metric renders "n/a" rather than an +// invented value. + +import { + INFLECTION_POINTS, + FOCUS_AREAS, + FIELD_COLOR, + FIELD_INK, + HAND_COLOR, + LIVE_COLOR, + type FocusAreaKey, + type InflectionPoint, +} from '@/lib/inflection-points' + +// Re-export the shared color + two-axis primitives so components import from one +// place and the theme-aware dark-mode handling is reused verbatim. +export { FIELD_COLOR, FIELD_INK, HAND_COLOR, LIVE_COLOR } +export { FOCUS_AREAS, INFLECTION_POINTS } +export type { FocusAreaKey, InflectionPoint } + +// ── The toolkit — PL R&D interventions ──────────────────────────────────────── +// Seven interventions we run to move a field. The short entry (title, subtitle, +// oneLiner) renders in the methodology grid; the full description renders in the +// modal. + +export type ToolId = + | 'legibility' + | 'connection' + | 'funding' + | 'policy' + | 'infrastructure' + | 'translation' + | 'culture' + +export type ToolkitEntry = { + id: ToolId + title: string + subtitle: string + proposed?: boolean + /** One-line description for the scannable methodology grid. */ + oneLiner: string + /** Fuller, published-style description shown in the modal. */ + description: string +} + +export const TOOLKIT_V2: ToolkitEntry[] = [ + { + id: 'legibility', + title: 'Legibility', + subtitle: 'field-building communications', + oneLiner: + 'Maps, roadmaps, benchmarks, and builder-grade tutorials that make a field navigable for researchers and working builders.', + description: + 'Many early fields are blocked by illegibility, to both internal and external audiences. The progress is real, but the map is missing. Researchers cannot easily find adjacent teams. Founders cannot tell which problems are ready for company formation. Funders cannot distinguish noise from signal. Policymakers cannot see which interventions would help rather than slow progress. Written artifacts like a field map, taxonomy, roadmap, whitepaper, benchmark, policy brief, or public research agenda can turn a loose collection of teams, researchers, and funders into a field with sustained momentum. Legibility also has a second audience: working builders. A field is fully legible only when a competent outsider can build in it, so tutorials, reference builds, teardowns, and partnerships with hands-on educators count as field-building communications alongside the papers.', + }, + { + id: 'connection', + title: 'Connection', + subtitle: 'convenings & sustained cohorts', + oneLiner: + 'Rooms that keep the right people in contact over time: retreats, residencies, advisory networks, apprenticeship-style pairings.', + description: + 'Some bottlenecks are social instead of technical. The right talent may already exist to unlock a breakthrough, but the field has not yet connected them. PL R&D runs and supports convenings, retreats, workshops, residencies, hackathons, and advisory networks that bring high-context people together around specific bottlenecks. Two design rules do most of the work. Mix kinds, not just seniority: the productive rooms pair researchers with builders and operators, because that pairing is where research turns into working systems. And sustain the contact: innovation spreads person to person, so recurring cohorts, alumni networks, and apprenticeship-style pairings matter more than one-off events. The goal is not to host events, but to forge new collaborations, companies, grants, pilots, and enable shared roadmaps to emerge.', + }, + { + id: 'funding', + title: 'Funding', + subtitle: 'grants, prizes & fellowships', + oneLiner: + 'Early money where small amounts unlock disproportionate downstream effort; prizes that define what "first" means for a field; fellowships and residencies that give outsiders hands-on entry.', + description: + 'Many important ideas are too early for venture capital and too applied for traditional academic funding. Some need small grants to test whether a primitive works. Others need prizes, RFPs, fellowships, or retroactive funding to pull effort into an underbuilt area. A well-designed prize does more than pay out: it defines what counts as "first" in a field and sets the ambition bar for everyone watching. Where breakthrough variance is the goal, we prefer funding people over projects, with longer horizons and tolerance for early failure. Some of the highest-return grants buy entry rather than output: fellowships, residencies with equipment access, and support for the educators who give newcomers their first hands-on contact with a field. PL R&D deploys and helps coordinate early funding where a field-level bottleneck is clear and where small early funding can unlock much larger downstream effort.', + }, + { + id: 'policy', + title: 'Policy', + subtitle: 'standards & experimentation rights', + oneLiner: + 'Clearing adoption paths for working technology, and legal room for garage-scale experimentation.', + description: + 'Frontier technologies often reach a point where the rules do not yet recognize what is possible. That can block adoption even when the technology works. In those cases, progress may require standards bodies, regulatory sandboxes, procurement pathways, model frameworks, or partnerships with public institutions. Adoption is half the job. Fields also need legal room to experiment at small scale: sandbox access for individuals and not only companies, hardware that may be repaired and modified, and workable pathways for citizen science in regulated domains. Many of the people a field will depend on in 10 years are amateurs today, and rules written only for incumbents quietly price them out. PL R&D engages in research, thought leadership, and participation in policy and standards-setting discussions with the goal of creating better rules, which, in turn, help responsible technologies reach users faster.', + }, + { + id: 'infrastructure', + title: 'Infrastructure', + subtitle: 'primitives, rails & cheap tools', + oneLiner: + 'Open protocols, shared facilities, and reference designs that collapse the cost of trying things; content-addressed registries that timestamp who built what, when.', + description: + 'Sometimes the missing piece is open protocols, public-good infrastructure, or neutral rails that no single company has sufficient incentive to build alone. When a missing primitive or shared infrastructure layer is blocking a field, PL R&D may help build it directly, support the teams building it, or coordinate an ecosystem capable of maintaining it over time. Two kinds of rails get special attention. Tool-cost infrastructure: open reference designs, shared facilities, and instrument cost-down programs that collapse the price of trying things, especially in hardware fields where a single experiment can cost a career. And attribution infrastructure: content-addressed, timestamped records of who built what, and when, so that priority and credit work for protocols, designs, datasets, and negative results the way they already work for papers.', + }, + { + id: 'translation', + title: 'Translation', + subtitle: 'venture conversion & visibility', + oneLiner: + 'Routing validated work to pilots, production, and capital, including surfacing independent inventors the world cannot yet see.', + description: + 'Some work reaches the point where the bottleneck is no longer research but translation into pilot, production, and scale. PL R&D connects projects within the broader Protocol Labs network, including venture, capital, platform, and ecosystem support, to help promising work find the right deployment path, talent, and resources to succeed. Translation also has a scouting side: much of the most promising invention happens outside institutions and stays invisible until someone surfaces it. Part of this tool is finding independent builders before the market can see them and lending them the network\u2019s visibility, alongside converting the projects we already know.', + }, + { + id: 'culture', + title: 'Culture', + subtitle: 'celebrating the improving mentality', + proposed: true, + oneLiner: + '(Proposed — our wildcard.) Deliberately open-ended creative bets: a film, a book, a photography prize that rewards human ingenuity, worldbuilding competitions. Whatever recruits people into the conviction that things can be improved, and that they can be the ones to improve them.', + description: + 'Every sustained acceleration of invention has run on a culture that treats improvement as a personal calling, spread from person to person. That culture is the hardest condition to engineer, so we treat this tool as our wildcard: deliberately open-ended creative bets that recruit people into building. That might mean backing a film or a book, a photography prize that rewards human ingenuity, or a worldbuilding competition that makes a field\u2019s future concrete enough to want. Ask inventors where they started and they often name an artifact: a book, a film, a kit. Most of these bets will miss. The hits compound for decades, and we will track them on the same scoreboard as everything else we fund.', + }, +] + +// The five velocity instruments and their per-focus-area readings now live in +// `@/lib/velocity-instruments` (the single source of truth for that prose). diff --git a/src/lib/inflection-points.ts b/src/lib/inflection-points.ts new file mode 100644 index 00000000..18c39bf6 --- /dev/null +++ b/src/lib/inflection-points.ts @@ -0,0 +1,491 @@ +// Cross-focus-area inflection points and how we measure them. +// +// Each focus area names a small set of inflection points: specific, observable, +// falsifiable shifts we believe would be catalytic and that have NOT yet happened +// as of 2026. They are hypotheses, not descriptions of the present. We judge our +// work against three questions, mapped to the fields on every point: +// +// Q1 (cascade) Did it matter? The second-order effects it should unlock. +// Q2 (signal) Did it happen? A pre-registered threshold — yes/no + date. +// Q3 (contribution) Did our work make it happen? PL's inputs -> activities -> outputs on the +// critical path (the planned-work side of the +// logic model), traced honestly. +// +// IMPORTANT — these are distinct jobs and are NOT collapsed into one score. A point +// can be reached (Q2) and matter (Q1) with low PL contribution (Q3); that is still a +// win for the field. So we track field progress (Q1 & Q2) on one axis and PL's +// contribution (Q3) on a separate, independent axis. +// +// Source: "Inflection points across PL R&D, and how we will measure them." + +/** PL's pre-registered role(s) on the critical path — claims to be evidenced, not a credit score. */ +export type PLRole = 'infrastructure' | 'legibility' | 'connection' | 'capital' | 'translation' | 'permission' + +/** Canonical display order for role pills, so cards read consistently. */ +export const PL_ROLE_ORDER: PLRole[] = ['infrastructure', 'legibility', 'connection', 'capital', 'translation', 'permission'] + +// Two-axis color system used across the impact dashboard: +// FIELD = the change in the world (outcomes + impact) — moves with or without us (black) +// HAND = our planned work / the PL toolkit — the axis we control with partners (PL blue) +// These resolve to theme-aware CSS variables (see globals.css) so the black +// “field” ink flips to a light tone in dark mode instead of vanishing. +export const FIELD_COLOR = 'var(--impact-field)' +export const HAND_COLOR = 'var(--impact-hand)' +/** Text/ink color to place on a FIELD_COLOR fill (white in light, dark in dark). */ +export const FIELD_INK = 'var(--impact-field-ink)' +/** Live-signal accent — the pulsing dot on points with live outputs (green). */ +export const LIVE_COLOR = '#22c55e' + +/** + * The one-line contract for reading the whole dashboard. Shared by the + * "How to read this" overlay (near the cards) and the methodology callout so + * the framing lives in exactly one place. + */ +export const HOW_TO_READ = + 'We don’t claim credit for outcomes. We name a falsifiable threshold, state what we contributed, and show live signal for and against. Judge us on whether our thresholds prove right and our contributions prove load-bearing.' + +/** + * Q3 contribution, structured along the planned-work side of the logic model. + * Inputs (resources committed) -> Activities (what we do) -> Outputs (what gets produced). + * The outcomes and impact these aim at are tracked on the field axis (Q2, Q1). + */ +export type Contribution = { + inputs: string + activities: string + outputs: string +} + +/** A pointer to live activity that is contribution evidence (Q3) — NOT a Q2 threshold reading. */ +export type LiveEvidence = { + label: string + href: string + note: string +} + +// ── Resolution: outcome × mattered ──────────────────────────────────────────── +// A single hit/miss cannot express the failure this page exists to catch: a +// marker that resolves true while the field goes nowhere. Outcome (did the +// marker resolve) and mattered (did it move the field) are independent, and +// `mattered` is never derived from `outcome`. +export type Outcome = 'pending' | 'reached' | 'missed' | 'retired' +export type Mattered = 'unknown' | 'too_early' | 'yes' | 'no' + +export type InflectionPoint = { + /** Focus-area slug, matches /areas/. */ + area: FocusAreaKey + /** The opportunity space this bet lives in. */ + opportunitySpace: string + /** The inflection point, stated as a hypothesis. */ + title: string + /** Q2 — observable threshold that says it happened (not yet true). */ + signal: string + /** Q1 — why it matters / the cascade to watch. */ + cascade: string + /** Q3 — PL contribution to trace, as inputs -> activities -> outputs. */ + contribution: Contribution + /** Q3, summarized as the PL role(s) on the critical path — the instruments we bring. */ + roles: PLRole[] + /** Optional live activity / real-world signals — strictly Q3 evidence, never Q2 progress. */ + liveEvidence?: LiveEvidence[] + + // ── Resolution (Task 4). Seeded pending/unknown with null dates until a real + // value lands; the nulls are rendered visibly rather than hidden. ── + outcome?: Outcome + mattered?: Mattered + /** REQUIRED when mattered is 'yes' or 'no' (enforced at build time). */ + matteredEvidence?: string + /** A missed marker where the field accelerated by another path. */ + fieldMovedAnyway?: boolean + /** REQUIRED when outcome is 'retired' (enforced at build time). */ + retiredReason?: string + /** ISO date, nullable — null renders as “date not yet set”. */ + predictedBy?: string | null + /** The condition that would prove us wrong — null renders as “condition not yet set”. */ + falsifiesIf?: string | null + /** ISO date of the last status review. */ + asOf?: string +} + +export type FocusAreaKey = + | 'digital-human-rights' + | 'economies-governance' + | 'ai-robotics' + | 'neurotech' + +export type FocusAreaMeta = { + key: FocusAreaKey + label: string + /** Short legacy code used in the strategy docs. */ + code: string + href: string + /** Accent color (hex from the theme) for badges and the field-progress meter. */ + accent: string + /** True when the focus area's inflection points are deliberately not-yet-defined (not empty). */ + forthcoming?: boolean +} + +export const FOCUS_AREAS: FocusAreaMeta[] = [ + { key: 'digital-human-rights', label: 'Digital Human Rights', code: 'FA1', href: '/areas/digital-human-rights/', accent: '#1982F4' }, + { key: 'economies-governance', label: 'Economies & Governance', code: 'FA2', href: '/areas/economies-governance/', accent: '#12bfdf' }, + { key: 'ai-robotics', label: 'AI & Robotics', code: 'FA3', href: '/areas/ai-robotics/', accent: '#3966FE', forthcoming: true }, + { key: 'neurotech', label: 'Neurotech', code: 'FA4', href: '/areas/neurotech/', accent: '#E51A66' }, +] + +// The logic-model chain: planned work (inputs/activities/outputs) → intended +// results (outcomes/impact). Shared by the Measuring-impact section, the three- +// questions explainer, and the per-point detail modal so the vocabulary lives +// in one place. +export const LOGIC_MODEL = [ + { key: 'inputs', label: 'Inputs', body: 'Funding, teams, convenings, standards we commit.' }, + { key: 'activities', label: 'Activities', body: 'Seeding teams, building primitives, setting standards.' }, + { key: 'outputs', label: 'Outputs', body: 'Teams funded, deployments, papers, ventures.' }, + { key: 'outcomes', label: 'Outcomes', body: 'Adoption, capital inflows, new entrants.' }, + { key: 'impact', label: 'Impact', body: 'The lasting shift: an inflection point that holds.' }, +] as const +export type LogicStageKey = (typeof LOGIC_MODEL)[number]['key'] + +/** + * Team / venture display name -> canonical website. Used to linkify the named + * examples that appear in contribution copy (activities / outputs) so each + * mentioned team points to its own site. Keep names exactly as they are written + * in the copy (minus trailing qualifiers like "(World)"). + */ +export const TEAM_LINKS: Record = { + 'Funding the Commons': 'https://fundingthecommons.io/', + 'Tools for Humanity': 'https://www.toolsforhumanity.com/', + 'Impossible Cloud': 'https://www.impossiblecloud.com/', + Hypercerts: 'https://hypercerts.org/', + GainForest: 'https://gainforest.earth/', + WeatherXM: 'https://weatherxm.com/', + Molecule: 'https://www.molecule.xyz/', + 'EQTY Lab': 'https://eqtylab.io/', + SpruceID: 'https://spruceid.com/', + Huddle01: 'https://huddle01.com/', + Spheron: 'https://spheron.network/', + Filecoin: 'https://filecoin.io/', + Fluence: 'https://fluence.network/', + Expanso: 'https://www.expanso.io/', + libp2p: 'https://libp2p.io/', + Privy: 'https://privy.io/', + Fleek: 'https://fleek.co/', + Glow: 'https://glowlabs.org/', + Lava: 'https://www.lavanet.xyz/', + IPFS: 'https://ipfs.io/', +} + +export const ROLE_META: Record = { + infrastructure: { + label: 'Infrastructure', + description: 'The open, neutral rail this runs on did not exist. PL builds and maintains it (libp2p / IPFS / Filecoin lineage).', + }, + legibility: { + label: 'Legibility', + description: 'The field lacked a shared map. PL adds roadmaps, taxonomies, benchmarks, and written artifacts.', + }, + connection: { + label: 'Connection', + description: 'Too few connections blocked progress. PL convenes the people who need to collide: dinners, retreats, residencies, hackathons.', + }, + capital: { + label: 'Funding', + description: 'Pre-commercial work needed patient funding. PL runs grants and prizes and helps peer funders deploy theirs.', + }, + translation: { + label: 'Translation', + description: 'The work was ready to leave the lab. PL helps convert it into ventures, pilots, and deployments.', + }, + permission: { + label: 'Policy', + description: 'The rules did not yet recognize the technology. PL engages standards, policy, and regulatory pathways.', + }, +} + +export const INFLECTION_POINTS: InflectionPoint[] = [ + // ── FA1 · Digital Human Rights ─────────────────────────────────────────── + { + area: 'digital-human-rights', + opportunitySpace: 'Censorship-Resistant Communication', + title: 'Communication that cannot be switched off', + signal: + 'A consumer-scale connectivity provider operates without state licensing or identity gating, and a metadata-resistant messenger crosses tens of millions of users; a population stays connected through a deliberate shutdown.', + cascade: + 'Censoring speech and assembly becomes impractical, not just illegal; organizing survives adversarial conditions.', + contribution: { + inputs: 'The libp2p / IPFS open-source networking stack; funding for resilient-comms and private-messaging teams.', + activities: 'Maintaining the substrate, funding teams, and contributing to interoperability standards.', + outputs: 'libp2p / IPFS deployments and the funded comms / messaging teams building on them, such as Fluence and Huddle01.', + }, + roles: ['infrastructure', 'capital'], + liveEvidence: [ + { + label: 'Wikipedia kept online via IPFS during Turkey’s block', + href: 'https://observer.com/2017/05/turkey-wikipedia-ipfs/', + note: 'A content-addressed (IPFS) mirror kept Wikipedia reachable under Turkey’s state block — early evidence that information can route around a shutdown, not the population-scale-through-a-shutdown threshold (Q2).', + }, + ], + }, + { + area: 'digital-human-rights', + opportunitySpace: 'Portable Identity, Credentials & Trust', + title: 'Personhood without the state in the loop', + signal: + 'A service at >100M people verifies unique humans for everyday use without a nation-state ID or KYC.', + cascade: + 'Recognition stops being rented from platforms and states; privacy-preserving personhood becomes safe to build on.', + contribution: { + inputs: 'Convening capacity across identity protocols; seed funding for portable-credential initiatives.', + activities: 'Convening identity-protocol teams and seeding / funding portable-credential work.', + outputs: 'The identity and credential initiatives PL has seeded or funded, such as Tools for Humanity (World), SpruceID, and Privy.', + }, + roles: ['connection', 'capital'], + }, + { + area: 'digital-human-rights', + opportunitySpace: 'Verifiable Public Knowledge & Provenance', + title: 'Provenance becomes the default for truth', + signal: + 'Two consecutive frontier-model generations ship attested provenance by default, and a major platform or archive adopts content-addressed provenance as default.', + cascade: + 'A public record that can prove its own integrity becomes the precondition for trustworthy information in the AI era.', + contribution: { + inputs: 'Content addressing (IPFS) as the provenance substrate; funding for provenance and verifiable-compute teams.', + activities: 'Providing the content-addressing substrate and backing provenance / verifiable-compute teams.', + outputs: 'Content-addressed provenance tooling and the PL-backed teams building it, such as EQTY Lab.', + }, + roles: ['infrastructure', 'capital'], + liveEvidence: [ + { + label: 'Starling Lab — content-authenticity displays in newsrooms', + href: 'https://dispatch.starlinglab.org/p/verify-authenticity-displays', + note: 'Reuters, AP, Rolling Stone and others ship C2PA content credentials backed by content-addressed archives (IPFS / Filecoin) — early real-world provenance, not the default-everywhere threshold (Q2).', + }, + ], + }, + { + area: 'digital-human-rights', + opportunitySpace: 'Sovereign Infrastructure for AI & Agents', + title: 'Agents run on open rails', + signal: + 'A frontier-scale model is trained across independent decentralized hardware, or a meaningful share of agent-to-agent economic activity settles on open permissionless compute / storage / identity.', + cascade: + 'The rights architecture of the agent economy is set in the open rather than by whoever owns the cluster.', + contribution: { + inputs: 'The Filecoin / open-compute portfolio; PL capital and coordination across storage, compute, and identity.', + activities: 'Building open compute and storage rails and bridging them with identity for agents.', + outputs: 'Filecoin and the open-compute portfolio — Fluence, Spheron, Expanso, Impossible Cloud, Lava, Fleek — with integrations across storage, compute, and identity.', + }, + roles: ['infrastructure', 'connection', 'capital'], + }, + + // ── FA2 · Economies & Governance ───────────────────────────────────────── + { + area: 'economies-governance', + opportunitySpace: 'Sovereign Digital Public Infrastructure', + title: 'Programmable government in production', + signal: + 'At least one sovereign moves >$1B/yr of real public funds through programmable, real-time-auditable rails; 3+ jurisdictions use selective-disclosure credentials for a high-stakes function (election, passport, census).', + cascade: + 'Digital government crosses from digitization to transformation; a reference deployment others can copy.', + contribution: { + inputs: 'Standards and procurement playbooks; convening capacity with sovereigns and builders; funding for DPI primitives.', + activities: 'Writing standards and playbooks, convening sovereigns with builders, and funding DPI primitives.', + outputs: 'Published playbooks, convened sovereign–builder cohorts, and funded DPI primitives.', + }, + roles: ['connection', 'capital', 'permission'], + }, + { + area: 'economies-governance', + opportunitySpace: 'Computational Coordination & Governance', + title: 'A binding decision at scale', + signal: + 'A city or government makes a consequential, binding decision through a computational mechanism with tens of thousands participating, beating the legacy process on cost, turnout, or trust.', + cascade: + 'Deliberation tools move from advisory to part of real decision-making infrastructure.', + contribution: { + inputs: 'Support for computational-deliberation mechanisms (e.g. Simocracy, broad-listening tools); convening capacity.', + activities: 'Supporting mechanism teams and convening government teams with tool teams.', + outputs: 'Simocracy and broad-listening tools, and the government–tool convenings around them.', + }, + roles: ['connection', 'capital'], + liveEvidence: [ + { + label: 'Simocracy governance simulation — live participation', + href: '/areas/economies-governance/impact/live-dashboard/', + note: 'Live activity from a PL-supported mechanism. This is contribution evidence (Q3) from a simulation — not the binding-decision-in-government threshold (Q2).', + }, + ], + }, + { + area: 'economies-governance', + opportunitySpace: 'Programmable Capital Allocation', + title: 'Public goods become a financeable category', + signal: + '>$1B/yr flows through programmable allocation, a mainstream allocator (DFI, pension, sovereign fund) treats it as infrastructure, and a material real-world outcome is documented.', + cascade: + 'Funding public goods becomes a durable capital market rather than a niche.', + contribution: { + inputs: 'Hypercerts (PL origin) and the Gitcoin / Funding-the-Commons lineage; PL evidence and standards work.', + activities: 'Originating allocation mechanisms, building evidence and standards, and converting work into ventures.', + outputs: 'Hypercerts, Funding the Commons, and ventures spun out of / funded across this lineage such as Molecule.', + }, + roles: ['infrastructure', 'capital', 'translation'], + }, + { + area: 'economies-governance', + opportunitySpace: 'Verifiable Real-World Infrastructure & Systems', + title: 'Capital that pays on verified outcomes', + signal: + 'A $1B+ climate or public-goods fund disburses against independently verified real-world outcomes, faster and cheaper than a legacy audit.', + cascade: + 'Verification becomes the rail capital runs on; the loop back to better decisions closes.', + contribution: { + inputs: 'Funding for MRV / outcome-verification teams (e.g. GainForest, Glow); benchmark and standards convening.', + activities: 'Backing MRV teams and convening benchmark and standards work for outcome verification.', + outputs: 'GainForest, Glow, WeatherXM, and the verification benchmarks and standards they inform.', + }, + roles: ['legibility', 'connection', 'capital'], + liveEvidence: [ + { + label: 'GainForest & Glow — live verification activity', + href: '/areas/economies-governance/impact/live-dashboard/', + note: 'Live output from PL-backed MRV teams on this critical path. Contribution evidence (Q3) — not the $1B-disbursed-against-verified-outcomes threshold (Q2).', + }, + ], + }, + + // ── FA4 · Neurotech ────────────────────────────────────────────────────── + { + area: 'neurotech', + opportunitySpace: 'Neural Augmentation (BCI)', + title: 'The BCI app store', + signal: + 'A regulator clears a standardized software / API layer for a commercial BCI (with medical guardrails), and the first third-party app launches and is adopted.', + cascade: + 'BCI value compounds after surgery; a developer ecosystem forms; demand shifts toward elective use.', + contribution: { + inputs: 'PL Neuro standards work for a BCI component ecosystem; convening capacity across regulators, device makers, and developers.', + activities: 'Defining the BCI component-ecosystem standard and convening regulators, makers, and developers.', + outputs: 'A draft BCI component / API standard and the regulator–maker–developer convenings around it.', + }, + roles: ['connection', 'permission'], + }, + { + area: 'neurotech', + opportunitySpace: 'Biologically Inspired Intelligence (NeuroAI)', + title: 'Neural distillation', + signal: + 'A major lab matches frontier reasoning by training on human neural data at a fraction of the parameters / energy, or venture funding surges into consumer EEG to harvest cognitive data for AI.', + cascade: + 'High-fidelity neural data becomes as valuable to AI as text; comp-neuro talent flow reverses.', + contribution: { + inputs: 'The PL Neuro talent network bridging AI labs and comp neuro; investment in neural-data infrastructure and norms.', + activities: 'Bridging AI-lab and comp-neuro talent and building neural-data infrastructure and norms.', + outputs: 'The PL Neuro talent network and the neural-data infrastructure and norms it seeds.', + }, + roles: ['infrastructure', 'connection', 'capital'], + }, + { + area: 'neurotech', + opportunitySpace: 'Biologically Inspired Intelligence (NeuroAI)', + title: 'The neuromorphic energy pivot', + signal: + 'A brain-inspired model matches state-of-the-art performance at 3+ orders of magnitude better energy efficiency, and frontier labs begin acquiring neuromorphic startups.', + cascade: + 'Neurally derived design becomes foundational to commercial AI; AI is tethered to neuroscience.', + contribution: { + inputs: 'Funding for NeuroAI / neuromorphic research and demos; benchmark design defining the efficiency target.', + activities: 'Funding neuromorphic research and demos and defining the efficiency benchmark.', + outputs: 'PL-funded NeuroAI demos and the energy-efficiency benchmarks that frame the target.', + }, + roles: ['legibility', 'capital'], + }, + { + area: 'neurotech', + opportunitySpace: 'Whole Organism Emulation (WBE)', + title: 'Memory retrieval in simulation', + signal: + 'A reconstructed mouse connectome simulated in silico reproduces a specific behavior the biological mouse learned before its connectome was harvested.', + cascade: + 'WBE turns from speculation into a benchmarked engineering discipline; serious policy engagement begins.', + contribution: { + inputs: 'PL Neuro benchmark definition; connectomics workshops and throughput targets; engineering capacity for a demo.', + activities: 'Defining the WBE benchmark, running connectomics workshops, and engineering a reference demo.', + outputs: 'The WBE benchmark, connectomics throughput targets, and a PL-engineered demo.', + }, + roles: ['legibility', 'connection'], + }, +] + +export function pointsForArea(area: FocusAreaKey | 'all'): InflectionPoint[] { + if (area === 'all') return INFLECTION_POINTS + return INFLECTION_POINTS.filter((p) => p.area === area) +} + +// ── Resolution helpers ──────────────────────────────────────────────────────── +// Every existing marker is seeded pending / unknown with null date and null +// condition (no invented values). Real values, when they land, override here. +export type Resolution = { + outcome: Outcome + mattered: Mattered + matteredEvidence?: string + fieldMovedAnyway: boolean + retiredReason?: string + predictedBy: string | null + falsifiesIf: string | null + asOf?: string +} + +export function resolutionFor(p: InflectionPoint): Resolution { + return { + outcome: p.outcome ?? 'pending', + mattered: p.mattered ?? 'unknown', + matteredEvidence: p.matteredEvidence, + fieldMovedAnyway: p.fieldMovedAnyway ?? false, + retiredReason: p.retiredReason, + predictedBy: p.predictedBy ?? null, + falsifiesIf: p.falsifiesIf ?? null, + asOf: p.asOf, + } +} + +/** The one place the display label is derived. `mattered` is never inferred. */ +export function inflectionLabel(p: InflectionPoint): string { + const r = resolutionFor(p) + switch (r.outcome) { + case 'pending': + return 'pending' + case 'retired': + return 'retired — superseded' + case 'missed': + return r.fieldMovedAnyway ? 'missed — field moved another way' : 'missed' + case 'reached': + if (r.mattered === 'yes') return 'reached — field moved' + if (r.mattered === 'no') return 'reached — no lift' + return 'reached — lift unclear' // too_early | unknown + } +} + +/** Markers the misses affordance surfaces: missed, retired, or reached-without-lift. */ +export function isConcerningMarker(p: InflectionPoint): boolean { + const r = resolutionFor(p) + return r.outcome === 'missed' || r.outcome === 'retired' || (r.outcome === 'reached' && r.mattered === 'no') +} + +/** Stable id for anchoring/linking a marker card. */ +export function inflectionSlug(p: InflectionPoint): string { + return `ip-${p.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}` +} + +// ── Build-time assertion (Task 4 rules) ─────────────────────────────────────── +export function assertInflectionResolution(): void { + for (const p of INFLECTION_POINTS) { + if ((p.mattered === 'yes' || p.mattered === 'no') && !p.matteredEvidence) { + throw new Error(`inflection-points: "${p.title}" has mattered=${p.mattered} but no matteredEvidence`) + } + if (p.outcome === 'retired' && !p.retiredReason) { + throw new Error(`inflection-points: "${p.title}" is retired but has no retiredReason`) + } + } +} + +assertInflectionResolution() diff --git a/src/lib/market-signals.ts b/src/lib/market-signals.ts new file mode 100644 index 00000000..5338f177 --- /dev/null +++ b/src/lib/market-signals.ts @@ -0,0 +1,361 @@ +// Prediction-market "field" signal for each inflection point. +// +// Each inflection point can be paired with the prediction market that most +// closely matches its pre-registered threshold (Q2). The platform is chosen per +// point on match quality, NOT convenience: +// - Polymarket — deepest liquidity for crypto/AI/identity/BCI-valuation themes. +// - Kalshi — regulated venue; better for US-regulatory / adoption-count themes. +// - Metaculus — reputation-weighted community forecasts for long-horizon, +// research-heavy bets no money market prices (e.g. WBE). Its API +// requires a token (METACULUS_API_TOKEN); without one we still +// link the question but show no live number. +// +// A market read is an *external, independent* estimate on the FIELD axis +// ("will it happen?") — it is never PL contribution (Q3) and never a settled Q2. +// Where no market exists, that gap is itself informative: the bet is early or +// contrarian enough that no one is pricing it yet. + +export type Platform = 'polymarket' | 'kalshi' | 'metaculus' +export type SignalMatch = 'direct' | 'proxy' | 'gap' + +type MarketRef = + | { platform: 'polymarket'; slug: string; hint?: string; question: string; url: string } + | { platform: 'kalshi'; ticker: string; question: string; url: string } + | { platform: 'metaculus'; id: number; kind?: 'binary' | 'date'; question: string; url: string } + +export type MarketMapping = { + /** Must match InflectionPoint.title exactly. */ + title: string + match: SignalMatch + /** Best-match market (drives the platform label). Omitted for gaps. */ + primary?: MarketRef + /** Liquidity fallback used only if the primary returns no live price. */ + fallback?: MarketRef + note: string +} + +export type MarketSignal = { + match: SignalMatch + platform: Platform | null + /** 0..1 crowd "yes" probability, or null when unavailable. */ + prob: number | null + /** Total money traded through the market, in USD, or null when unavailable. */ + volume: number | null + question: string | null + url: string | null + /** Pre-formatted headline reading for non-probability markets (e.g. a Metaculus date estimate, “~2068”). */ + readout?: string + /** True when prob came from the fallback rather than the best-match market. */ + viaFallback: boolean + note: string +} + +// ── Mapping: inflection point → closest market ──────────────────────────────── +export const MARKET_MAPPINGS: MarketMapping[] = [ + { + title: 'Communication that cannot be switched off', + match: 'gap', + note: 'No liquid market prices a metadata-resistant messenger at scale or surviving a shutdown — a genuine white space.', + }, + { + title: 'Personhood without the state in the loop', + match: 'proxy', + primary: { + platform: 'polymarket', + slug: 'over-30m-humans-verified-on-world-network-by-december-31', + question: 'Over 30M humans verified on World Network by Dec 31?', + url: 'https://polymarket.com/event/over-30m-humans-verified-on-world-network-by-december-31', + }, + note: 'Proof-of-human adoption at scale is the closest live analogue; Polymarket has the deepest World/identity liquidity.', + }, + { + title: 'Provenance becomes the default for truth', + match: 'proxy', + primary: { + platform: 'kalshi', + ticker: 'KXAILEGISLATION-27-JAN01', + question: 'Will AI regulation become US law by 2027?', + url: 'https://kalshi.com/markets/kxailegislation/ai-regulation', + }, + note: 'Provenance mandates ride on AI regulation; Kalshi (regulated, US-policy focus) is the better-matched venue than any crypto market.', + }, + { + title: 'Agents run on open rails', + match: 'proxy', + primary: { + platform: 'polymarket', + slug: 'us-government-removes-public-access-to-another-major-ai-model-in-2026-20260703202936862', + question: 'US government removes public access to another major AI model in 2026?', + url: 'https://polymarket.com/event/us-government-removes-public-access-to-another-major-ai-model-in-2026-20260703202936862', + }, + note: 'A contrarian, inverse read: if Washington moves to cut off public access to a major (open, largely Chinese) AI model, that is a headwind for open, permissionless rails — high odds here cut against this inflection point, not for it.', + }, + { + title: 'Programmable government in production', + match: 'gap', + note: 'No liquid market prices $1B/yr of public funds moving through programmable, real-time-auditable rails, or selective-disclosure credentials at national scale — a genuine white space.', + }, + { + title: 'A binding decision at scale', + match: 'gap', + note: 'No market prices a binding, at-scale computational decision. We already surface a live Simocracy signal here (Q3 contribution, not Q2).', + }, + { + title: 'Public goods become a financeable category', + match: 'gap', + note: 'No liquid market prices $1B/yr flowing through programmable allocation — long-horizon white space.', + }, + { + title: 'Capital that pays on verified outcomes', + match: 'gap', + note: 'No market prices a $1B+ outcomes-verified climate fund.', + }, + { + title: 'The BCI app store', + match: 'proxy', + primary: { + platform: 'kalshi', + ticker: 'KXNEURALINKCOUNT-26-40', + question: 'Will Neuralink implant ≥40 people in 2026?', + url: 'https://kalshi.com/markets/kxneuralinkcount/neuralink-count', + }, + fallback: { + platform: 'polymarket', + slug: 'will-neuralinks-valuation-hit-by-december-31', + hint: '$75B', + question: "Will Neuralink's valuation hit $75B by Dec 31?", + url: 'https://polymarket.com/event/will-neuralinks-valuation-hit-by-december-31', + }, + note: 'Implant-count (Kalshi) is the best adoption/scaling proxy for a commercial BCI ecosystem; Polymarket valuation is the liquidity fallback.', + }, + { + title: 'Neural distillation', + match: 'proxy', + primary: { + platform: 'metaculus', + id: 372, + question: 'Will brain emulation be the first successful route to human-level AI?', + url: 'https://www.metaculus.com/questions/372/brain-emulation-produces-first-human-ai/', + }, + note: 'The closest live forecast: Metaculus’ long-running question on whether brain emulation is the first route to human-level AI — a reputation-weighted proxy for how seriously the crowd takes neural-data-driven paths to frontier AI. No money at stake, and the crowd prices it in the low single digits.', + }, + { + title: 'The neuromorphic energy pivot', + match: 'gap', + note: 'No market prices a neuromorphic model matching SOTA at 1000× efficiency — pure white space.', + }, + { + title: 'Memory retrieval in simulation', + match: 'proxy', + primary: { + platform: 'metaculus', + id: 2813, + kind: 'date', + question: 'When will the first human whole-brain emulation happen?', + url: 'https://www.metaculus.com/questions/2813/date-of-first-human-whole-brain-emulation/', + }, + note: 'The closest live forecast: Metaculus’ community estimate for the date of the first human whole-brain emulation — the crowd currently places it decades out. A reputation-weighted forecast, no money at stake.', + }, +] + +export function mappingByTitle(): Record { + return Object.fromEntries(MARKET_MAPPINGS.map((m) => [m.title, m])) +} + +// ── Fetchers ───────────────────────────────────────────────────────────────── +const GAMMA = 'https://gamma-api.polymarket.com' +const KALSHI = 'https://api.elections.kalshi.com/trade-api/v2' +const METACULUS = 'https://www.metaculus.com/api' +const METACULUS_TOKEN = process.env.METACULUS_API_TOKEN +const REVALIDATE = 3600 + +function safeParse(s?: string): string[] | null { + if (!s) return null + try { + const v = JSON.parse(s) + return Array.isArray(v) ? v.map(String) : null + } catch { + return null + } +} + +function distinctiveToken(s?: string): string | null { + if (!s) return null + const money = s.match(/\$[0-9]+(?:\.[0-9]+)?[bmk]?/i) + if (money) return money[0].toLowerCase() + const num = s.match(/[0-9]+m\b|[0-9]{2,}/i) + return num ? num[0].toLowerCase() : null +} + +type Quote = { prob: number | null; volume: number | null; readout?: string } + +async function fetchPolymarket(slug: string, hint?: string): Promise { + try { + const res = await fetch(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`, { + next: { revalidate: REVALIDATE }, + }) + if (!res.ok) return null + const events = (await res.json()) as Array<{ + title?: string + markets?: Array<{ question?: string; outcomes?: string; outcomePrices?: string; volumeNum?: number }> + }> + const markets = events?.[0]?.markets + if (!markets?.length) return null + const parsed = markets + .map((m) => { + const outcomes = safeParse(m.outcomes) + const prices = safeParse(m.outcomePrices) + if (!outcomes || !prices) return null + const yesIdx = outcomes.findIndex((o) => o.toLowerCase() === 'yes') + if (yesIdx === -1) return null + const yes = Number(prices[yesIdx]) + if (Number.isNaN(yes)) return null + const volume = typeof m.volumeNum === 'number' && m.volumeNum > 0 ? m.volumeNum : null + return { yes, volume, q: (m.question || '').toLowerCase() } + }) + .filter((x): x is { yes: number; volume: number | null; q: string } => x != null) + if (!parsed.length) return null + const token = distinctiveToken(hint) + const pick = (token && parsed.find((m) => m.q.includes(token))) || parsed[0] + return { prob: pick.yes, volume: pick.volume } + } catch { + return null + } +} + +async function fetchKalshi(ticker: string): Promise { + try { + const res = await fetch(`${KALSHI}/markets/${encodeURIComponent(ticker)}`, { + next: { revalidate: REVALIDATE }, + }) + if (!res.ok) return null + const m = ((await res.json()) as { market?: Record }).market + if (!m) return null + // dollar_volume is in USD; volume is in contracts (each settles $0-$1), so the + // contract count is a reasonable USD upper bound when no dollar figure is given. + const dollarVol = m.dollar_volume + const contractVol = m.volume + const volume = + typeof dollarVol === 'number' && dollarVol > 0 + ? dollarVol + : typeof contractVol === 'number' && contractVol > 0 + ? contractVol + : null + // Prices are in cents (0-100). Prefer last trade, else the bid/ask midpoint. + const last = m.last_price + if (typeof last === 'number' && last > 0) return { prob: last / 100, volume } + const bid = m.yes_bid + const ask = m.yes_ask + if (typeof bid === 'number' && typeof ask === 'number' && (bid > 0 || ask > 0)) { + return { prob: (bid + ask) / 2 / 100, volume } + } + return null + } catch { + return null + } +} + +// Metaculus gates its API behind a token. With METACULUS_API_TOKEN set we read +// the recency-weighted community prediction for a binary question; without it, +// or on any failure, we return null and the caller still links the question +// (prob shows as “—”). Metaculus has no money at stake, so volume is always null. +async function fetchMetaculus(id: number, kind: 'binary' | 'date' = 'binary'): Promise { + if (!METACULUS_TOKEN) return null + try { + const res = await fetch(`${METACULUS}/posts/${id}/`, { + headers: { Authorization: `Token ${METACULUS_TOKEN}` }, + next: { revalidate: REVALIDATE }, + }) + if (!res.ok) return null + const d = (await res.json()) as { + question?: { + scaling?: { range_min?: number; range_max?: number } + aggregations?: { recency_weighted?: { latest?: { centers?: number[]; means?: number[] } } } + } + } + const q = d.question + const latest = q?.aggregations?.recency_weighted?.latest + const center = latest?.centers?.[0] ?? latest?.means?.[0] + if (typeof center !== 'number') return null + if (kind === 'date') { + // Date questions report the community CDF location in 0..1; unscale it + // linearly across the question's range (unix seconds) to a median year. + const min = q?.scaling?.range_min + const max = q?.scaling?.range_max + if (typeof min !== 'number' || typeof max !== 'number') return null + const year = new Date((min + center * (max - min)) * 1000).getFullYear() + if (!Number.isFinite(year)) return null + return { prob: null, volume: null, readout: `~${year}` } + } + return { prob: center, volume: null } + } catch { + return null + } +} + +async function fetchRef(ref: MarketRef): Promise { + switch (ref.platform) { + case 'polymarket': + return fetchPolymarket(ref.slug, ref.hint) + case 'kalshi': + return fetchKalshi(ref.ticker) + case 'metaculus': + return fetchMetaculus(ref.id, ref.kind) + } +} + +/** Resolve one mapping into a display-ready signal (best-match, with fallback). */ +export async function resolveSignal(m: MarketMapping): Promise { + if (m.match === 'gap' || !m.primary) { + return { match: 'gap', platform: null, prob: null, volume: null, question: null, url: null, viaFallback: false, note: m.note } + } + const primary = await fetchRef(m.primary) + if (primary != null) { + return { + match: m.match, + platform: m.primary.platform, + prob: primary.prob, + volume: primary.volume, + readout: primary.readout, + question: m.primary.question, + url: m.primary.url, + viaFallback: false, + note: m.note, + } + } + if (m.fallback) { + const fb = await fetchRef(m.fallback) + if (fb != null) { + return { + match: m.match, + platform: m.fallback.platform, + prob: fb.prob, + volume: fb.volume, + readout: fb.readout, + question: m.fallback.question, + url: m.fallback.url, + viaFallback: true, + note: m.note, + } + } + } + // Market exists but no live price available — still link it. + return { + match: m.match, + platform: m.primary.platform, + prob: null, + volume: null, + question: m.primary.question, + url: m.primary.url, + viaFallback: false, + note: m.note, + } +} + +/** Resolve every mapping, keyed by inflection-point title. */ +export async function resolveAllSignals(): Promise> { + const entries = await Promise.all( + MARKET_MAPPINGS.map(async (m) => [m.title, await resolveSignal(m)] as const), + ) + return Object.fromEntries(entries) +} diff --git a/src/lib/site-config.ts b/src/lib/site-config.ts index 1078cd16..70e11ae0 100644 --- a/src/lib/site-config.ts +++ b/src/lib/site-config.ts @@ -35,6 +35,7 @@ export const mainNav: NavItem[] = [ { name: 'Neurotech', url: '/areas/neurotech/' }, ], }, + { name: 'Impact', url: '/impact/' }, { name: 'Insights', url: '/insights/' }, { name: 'Team', url: '/authors/' }, ] diff --git a/src/lib/velocity-instruments.ts b/src/lib/velocity-instruments.ts new file mode 100644 index 00000000..02ccde5e --- /dev/null +++ b/src/lib/velocity-instruments.ts @@ -0,0 +1,459 @@ +// ── Velocity instruments — the single source of truth ───────────────────────── +// +// The five instruments we read a field's velocity with. The full description +// prose lives HERE and is rendered in exactly one place: the individual +// instrument definition modal. The aggregate "Field velocity" modal shows +// readings and charts only, and links each instrument title back here. +// +// Instrument records are declared PER FOCUS AREA and are deliberately ragged: a +// field declares only the instruments that apply to it. Where a reading is not +// live, the record is `unwired` (a named candidate metric plus its blocker) or +// `not_applicable` (a reason). We never fabricate a value, trend, date, or +// series — an honest `unwired` is a correct answer. + +import type { FocusAreaKey } from '@/lib/inflection-points' +import { LIVE_COLOR } from '@/lib/inflection-points' + +export type InstrumentId = + | 'performance_curves' + | 'latency_compression' + | 'idea_vintage' + | 'revealed_commitments' + | 'markets' + +export type VelocityInstrument = { + id: InstrumentId + label: string + /** Short, scannable description of what the instrument is. */ + subtitle: string + /** Fuller description — the only copy of this prose in the codebase. */ + description: string +} + +// Prose moved verbatim from the previous VELOCITY_MEASURES. +export const VELOCITY_INSTRUMENTS: VelocityInstrument[] = [ + { + id: 'performance_curves', + label: 'Performance curves', + subtitle: 'Cost or capability per unit, tracked over time.', + description: + 'How much a fixed unit of output costs, or how much capability one unit buys, tracked over time: dollars per genome sequenced, per watt of solar, per kWh of battery, transistors per chip. These are real rates with units attached, and they tend to fall along steady curves, which makes them the most predictable trend in a field. The first job in each field is to find its gating unit, the single cost that everything downstream waits on.', + }, + { + id: 'latency_compression', + label: 'Latency compression', + subtitle: 'The lag between pipeline stages, and whether it is shrinking.', + description: + 'The time it takes work to move from one stage to the next, and whether that lag is getting shorter: a preprint to a replication, a published method to a working open-source implementation, a demo to a shipped product. This tracks the pipeline itself rather than how much is flowing through it, so a shrinking lag is a direct read on acceleration.', + }, + { + id: 'idea_vintage', + label: 'Idea vintage', + subtitle: 'The age of the ideas new work builds on.', + description: + 'How old the concepts are that new work builds on. Fast-moving fields lean on young ideas; stagnant fields keep recombining old ones. It can be computed from the text a field already produces, which makes it cheap to track over time.', + }, + { + id: 'revealed_commitments', + label: 'Revealed commitments', + subtitle: 'Where people put money and careers, not just attention.', + description: + 'Where people actually place bets: talent switching into the field, external capital forming around it, and job postings that name the technology. Money and careers are commitments and carry more signal than press or social attention, which is easy to manufacture.', + }, + { + id: 'markets', + label: 'Markets', + subtitle: 'What forecast markets imply about when a milestone arrives.', + description: + 'What forecast markets and prediction platforms imply about when a milestone will arrive, and whether that date is pulling in. Asking the same milestone at several time horizons gives a term structure, a kind of yield curve for a field, where an earlier implied date means expected acceleration. These need careful reading: markets can be thin, questions can resolve ambiguously, and for an org whose main channel is attention, our own work can move the very markets we are reading.', + }, +] + +export const INSTRUMENT_BY_ID: Record = Object.fromEntries( + VELOCITY_INSTRUMENTS.map((i) => [i.id, i]), +) as Record + +// Rendered as an extra definition card alongside the five instruments in the +// methodology. Not an instrument, so it is never part of a reading record. +export const INFLECTION_EXPLAINER = { + id: 'inflection_points', + label: 'Inflection points', + subtitle: 'Dated, falsifiable shifts we expect an accelerating field to produce.', + description: + 'Alongside the five instruments, each field names a small set of inflection points: specific, dated, falsifiable shifts that an accelerating field should produce, each paired with a live signal. They are markers rather than targets. Reaching one is evidence the field is moving, and the live signal shows how close it is.', +} + +// ── Instrument records ──────────────────────────────────────────────────────── + +export type InstrumentState = 'reading' | 'unwired' | 'not_applicable' +export type Direction = 'accelerating' | 'flat' | 'decelerating' | 'unclear' + +export type InstrumentRecord = { + instrument: InstrumentId + state: InstrumentState + + // state: 'reading' + metric?: string + value?: string + trend?: string + direction?: Direction + window?: string + asOf?: string // ISO date + /** Primary series. `lo`/`hi` drive an optional confidence band; `reliable:false` + * marks trailing points that render dashed and are excluded from direction. */ + series?: { x: string | number; y: number; lo?: number; hi?: number; reliable?: boolean }[] + /** Rendering hint for the sparkline; doubling trends read best on a log axis. */ + seriesScale?: 'linear' | 'log' + /** Optional secondary line (e.g. a normalizer), rendered dashed and muted. */ + series2?: { x: string | number; y: number }[] + series2Label?: string + /** Frozen provenance surfaced in the modal so a series stays self-comparable. */ + provenance?: { query?: string; generated?: string } + sources?: { label: string; url: string }[] + + // state: 'unwired' + candidateMetric?: string + blocker?: string + owner?: string + + // state: 'not_applicable' + reason?: string +} + +export const DIRECTION_META: Record< + Direction, + { label: string; glyph: string; color: string } +> = { + accelerating: { label: 'Accelerating', glyph: '↗', color: LIVE_COLOR }, + flat: { label: 'Flat', glyph: '→', color: '#6b7280' }, + decelerating: { label: 'Decelerating', glyph: '↘', color: '#d0894b' }, + unclear: { label: 'Unclear', glyph: '~', color: '#9ca3af' }, +} + +// Per-focus-area instrument sets. Ragged by design: a field lists only the +// instruments that apply. Every `reading` is sourced; everything else is an +// honest `unwired` or `not_applicable`. See the seeding notes per field. +export const INSTRUMENT_RECORDS: Partial> = { + 'digital-human-rights': [ + { + instrument: 'performance_curves', + state: 'unwired', + candidateMetric: 'Cost of verifiable storage per TB-year (Filecoin / Arweave vs. a centralized baseline)', + blocker: 'No apples-to-apples public series exists yet across providers and durability guarantees.', + }, + { + instrument: 'latency_compression', + state: 'unwired', + candidateMetric: + 'Lag from a cryptographic primitive being published to a production open-source implementation (SNARK libraries are the cleanest series)', + blocker: 'Needs a curated corpus of primitive-to-implementation dates before a lag can be computed.', + }, + { + instrument: 'idea_vintage', + state: 'unwired', + candidateMetric: 'median reference age from OpenAlex', + blocker: 'awaiting first run of scripts/velocity/field_velocity_openalex.py', + owner: 'Lukas', + }, + { + instrument: 'revealed_commitments', + state: 'reading', + metric: 'Open-source crypto-ecosystem developers (a proxy for the builder base this field draws on)', + value: '23,613 monthly active developers (Nov 2024)', + trend: 'Total developers down ~7% year over year; established developers (2+ years) up 27%', + direction: 'flat', + window: '2023 → 2024', + asOf: '2024-12-12', + sources: [ + { label: 'Electric Capital — 2024 Developer Report', url: 'https://www.developerreport.com/developer-report' }, + { + label: 'CoinDesk — coverage of the 2024 report', + url: 'https://www.coindesk.com/tech/2024/12/12/solana-was-the-biggest-draw-for-new-crypto-developers-in-2024-electric-capital', + }, + ], + }, + { + instrument: 'markets', + state: 'unwired', + candidateMetric: 'Implied arrival dates across the identity and AI-regulation markets mapped to this field', + blocker: 'The term-structure aggregation is not built; the live crowd forecasts are shown per market below.', + }, + ], + 'economies-governance': [ + { + instrument: 'performance_curves', + state: 'not_applicable', + reason: + 'Programmable allocation and governance have no single manufactured unit whose cost gates the field, so there is no performance curve to read. Cost shows up as administrative overhead, tracked under revealed commitments instead.', + }, + { + instrument: 'latency_compression', + state: 'unwired', + candidateMetric: 'Pilot-to-production lag for digital public infrastructure', + blocker: 'Too few completed pilot-to-production transitions exist yet to time a lag.', + }, + { + instrument: 'idea_vintage', + state: 'unwired', + candidateMetric: 'median reference age from OpenAlex', + blocker: 'awaiting first run of scripts/velocity/field_velocity_openalex.py', + owner: 'Lukas', + }, + { + instrument: 'revealed_commitments', + state: 'unwired', + candidateMetric: 'Capital moved through programmable allocation (Gitcoin, Hypercerts, retroactive funding rounds)', + blocker: 'Figures are scattered across rounds with no single audited time series.', + }, + { + instrument: 'markets', + state: 'not_applicable', + reason: + 'No liquid forecast market prices programmable-allocation or digital-public-infrastructure milestones, so there is no market signal to read.', + }, + ], + 'ai-robotics': [ + { + instrument: 'performance_curves', + state: 'reading', + metric: '50%-task-completion time horizon on software tasks (the task length an agent finishes half the time)', + value: 'Doubling roughly every 7 months', + trend: '≈7-month doubling over six years', + direction: 'accelerating', + window: '2019 → 2025', + asOf: '2025-03-19', + sources: [ + { + label: 'METR — Measuring AI Ability to Complete Long Tasks', + url: 'https://metr.org/blog/2025-03-19-measuring-ai-ability-to-complete-long-tasks/', + }, + { label: 'arXiv:2503.14499', url: 'https://arxiv.org/abs/2503.14499' }, + ], + }, + { + instrument: 'latency_compression', + state: 'unwired', + candidateMetric: "Lag from a paper's publication to an open-weights reimplementation", + blocker: 'Needs a curated paper-to-reimplementation corpus.', + }, + { + instrument: 'idea_vintage', + state: 'unwired', + candidateMetric: 'median reference age from OpenAlex', + blocker: 'awaiting first run of scripts/velocity/field_velocity_openalex.py', + owner: 'Lukas', + }, + { + instrument: 'revealed_commitments', + state: 'unwired', + candidateMetric: + 'Job postings naming the technology, plus the parameter scale trained on decentralized compute', + blocker: 'No single audited public series exists yet.', + }, + { + instrument: 'markets', + state: 'unwired', + candidateMetric: 'Implied arrival date across the mapped AI milestone markets (their term structure)', + blocker: + 'The term-structure aggregation is not built, and the reflexivity caveat bites hardest here — our own work can move these markets.', + }, + ], + neurotech: [ + { + instrument: 'performance_curves', + state: 'reading', + metric: 'Simultaneously recorded neurons', + value: 'Up to ~3,000 neurons (dataset record)', + trend: 'Doubling roughly every 7 years since the 1950s', + direction: 'accelerating', + window: '1957 → 2022', + asOf: '2022-10-01', + // Frontier (running-maximum) points transcribed from the tracked dataset; + // plotted on a log axis, which is how this doubling trend is standardly shown. + series: [ + { x: 1957, y: 2 }, + { x: 1970, y: 8 }, + { x: 1981, y: 18 }, + { x: 1991, y: 82 }, + { x: 1993, y: 148 }, + { x: 2009, y: 744 }, + { x: 2014, y: 3200 }, + ], + seriesScale: 'log', + sources: [ + { label: 'Stevenson — Tracking Advances in Neural Recording (UConn)', url: 'https://stevenson.lab.uconn.edu/scaling/' }, + { + label: 'Stevenson & Kording (2011), Nature Neuroscience', + url: 'https://www.nature.com/articles/nn.2731', + }, + ], + }, + { + instrument: 'latency_compression', + state: 'unwired', + candidateMetric: 'Lag from animal study to first human trial, and from IDE approval to first implant', + blocker: 'The sample of completed transitions is too small to trend.', + }, + { + instrument: 'idea_vintage', + state: 'unwired', + candidateMetric: 'median reference age from OpenAlex', + blocker: 'awaiting first run of scripts/velocity/field_velocity_openalex.py', + owner: 'Lukas', + }, + { + instrument: 'revealed_commitments', + state: 'unwired', + candidateMetric: 'Implants placed against the 10k-by-2030 milestone, and capital raised by BCI companies', + blocker: 'Implant counts are not centrally reported, and capital figures are scattered.', + }, + { + instrument: 'markets', + state: 'unwired', + candidateMetric: + 'Implied arrival dates across the mapped BCI and whole-brain-emulation questions (Metaculus, Polymarket, Kalshi)', + blocker: 'The term-structure aggregation is not built; the live crowd forecasts are shown per market below.', + }, + ], +} + +export function instrumentsForArea(area: FocusAreaKey): InstrumentRecord[] { + return INSTRUMENT_RECORDS[area] ?? [] +} + +// ── OpenAlex ingestion merge ────────────────────────────────────────────────── +// A serializable payload (built server-side from a CSV) merged into the static +// records at render. Pure — no filesystem access here, so this module stays +// safe to import from the client bundle. + +export type OpenAlexArea = { + ideaVintage?: { + series: { x: number; y: number; lo?: number; hi?: number; reliable: boolean }[] + latest: { year: number; median: number } | null + early: { mean: number; from: number; to: number } | null + recent: { mean: number; from: number; to: number } | null + reliableWindow: string + direction: Direction + query?: string + generated?: string + } + revealed?: { + entrants: { x: number; y: number }[] + corpusShare: { x: number; y: number }[] + latest: { year: number; entrants: number } | null + window: string + direction: Direction + query?: string + generated?: string + } +} + +const OPENALEX_SOURCE = { label: 'OpenAlex (CC0)', url: 'https://openalex.org' } +const GENERATOR_SOURCE = { + label: 'scripts/velocity/field_velocity_openalex.py', + url: 'https://github.com/protocol/plrd.org/blob/main/scripts/velocity/field_velocity_openalex.py', +} + +/** Merge OpenAlex readings into a field's static records. Absent data is a no-op. */ +export function withOpenAlex(records: InstrumentRecord[], data?: OpenAlexArea | null): InstrumentRecord[] { + if (!data) return records + return records.map((r) => { + if (r.instrument === 'idea_vintage' && data.ideaVintage && data.ideaVintage.latest) { + const iv = data.ideaVintage + const latest = iv.latest as { year: number; median: number } + const early = iv.early + const recent = iv.recent + const trend = + early && recent && early.to !== recent.from + ? `${early.mean.toFixed(1)}y avg (${early.from}–${early.to}) → ${recent.mean.toFixed(1)}y avg (${recent.from}–${recent.to}); lower = fresher ideas` + : 'a falling median means the field is building on fresher ideas' + return { + instrument: 'idea_vintage', + state: 'reading', + metric: 'median age of references in new work (years)', + value: `${latest.median.toFixed(1)} years (${latest.year})`, + trend, + direction: iv.direction, + window: iv.reliableWindow, + asOf: iv.generated, + series: iv.series, + seriesScale: 'linear', + provenance: { query: iv.query, generated: iv.generated }, + sources: [OPENALEX_SOURCE, GENERATOR_SOURCE], + } + } + if (r.instrument === 'revealed_commitments' && data.revealed) { + const rv = data.revealed + const talentNote = + 'chart shows first-time authors entering the field (the talent-entry component); capital formation and job postings remain unwired' + const shared = { + series: rv.entrants, + seriesScale: 'linear' as const, + series2: rv.corpusShare, + series2Label: 'corpus share per 100k (normalizer)', + provenance: { query: rv.query, generated: rv.generated }, + } + if (r.state === 'reading') { + // Augment an existing sourced reading (e.g. DHR's Electric Capital figure) + // with the OpenAlex talent-entry chart; keep its own value and sources. + return { + ...r, + ...shared, + metric: r.metric ? `${r.metric} — ${talentNote}` : talentNote, + sources: [...(r.sources ?? []), OPENALEX_SOURCE, GENERATOR_SOURCE], + } + } + return { + instrument: 'revealed_commitments', + state: 'reading', + metric: + 'First-time authors entering the field each year — the talent-entry component of revealed commitments. Capital formation and job postings remain unwired.', + value: rv.latest ? `${rv.latest.entrants.toLocaleString()} first-time authors (${rv.latest.year})` : '—', + direction: rv.direction, + window: rv.window, + asOf: rv.generated, + ...shared, + sources: [OPENALEX_SOURCE, GENERATOR_SOURCE], + } + } + return r + }) +} + +// ── Build-time assertion ────────────────────────────────────────────────────── +// Runs when the data is imported during `next build`; a malformed record fails +// the build rather than shipping a silent gap. This is the enforcement the spec +// asks for (no test runner is configured in this repo). + +export function assertInstrumentRecords(): void { + const ids = new Set(VELOCITY_INSTRUMENTS.map((i) => i.id)) + for (const [area, records] of Object.entries(INSTRUMENT_RECORDS)) { + const seen = new Set() + for (const r of records ?? []) { + const where = `${area}/${r.instrument}` + if (!ids.has(r.instrument)) throw new Error(`velocity-instruments: unknown instrument ${where}`) + if (seen.has(r.instrument)) throw new Error(`velocity-instruments: duplicate instrument ${where}`) + seen.add(r.instrument) + + if (r.state === 'reading') { + if (!r.value) throw new Error(`velocity-instruments: reading ${where} needs a value`) + if (!r.asOf) throw new Error(`velocity-instruments: reading ${where} needs an asOf date`) + if (!r.sources || r.sources.length < 1) + throw new Error(`velocity-instruments: reading ${where} needs >=1 source`) + for (const s of r.sources) { + if (!s.url || !/^https?:\/\//.test(s.url)) + throw new Error(`velocity-instruments: reading ${where} has a source without a valid URL`) + } + } else if (r.state === 'unwired') { + if (!r.candidateMetric) throw new Error(`velocity-instruments: unwired ${where} needs a candidateMetric`) + if (!r.blocker) throw new Error(`velocity-instruments: unwired ${where} needs a blocker`) + } else if (r.state === 'not_applicable') { + if (!r.reason) throw new Error(`velocity-instruments: not_applicable ${where} needs a reason`) + } else { + throw new Error(`velocity-instruments: unknown state at ${where}`) + } + } + } +} + +assertInstrumentRecords() diff --git a/src/lib/velocity-openalex.ts b/src/lib/velocity-openalex.ts new file mode 100644 index 00000000..b2c40447 --- /dev/null +++ b/src/lib/velocity-openalex.ts @@ -0,0 +1,185 @@ +// ── OpenAlex CSV ingestion (build-time, server only) ────────────────────────── +// +// Parses one CSV per focus area written by scripts/velocity/field_velocity_openalex.py +// and turns it into a serializable OpenAlexArea payload merged into the instrument +// records (see withOpenAlex). Parsing happens here, at build time, never in the +// browser. If a field has no CSV, this returns null and the instrument stays the +// documented `unwired` record. +// +// CSV contract: +// Leading `#` lines are provenance (generated timestamp, source, the frozen +// query string, the reliability cutoff year, sampling parameters). Then: +// year,field_works,all_works,corpus_share_per_100k,first_time_authors, +// idea_vintage_median_years,vintage_ci_lo,vintage_ci_hi,n_refs_sampled,reliable + +import fs from 'node:fs' +import path from 'node:path' +import type { FocusAreaKey } from '@/lib/inflection-points' +import type { Direction, OpenAlexArea } from '@/lib/velocity-instruments' +import { ideaVintageDirection, trailingSlope, windowMeans } from '../../scripts/velocity/vintage-direction.mjs' + +const DATA_DIR = path.join(process.cwd(), 'src', 'data', 'velocity') +const AREA_SLUGS: FocusAreaKey[] = [ + 'digital-human-rights', + 'economies-governance', + 'ai-robotics', + 'neurotech', +] + +type Row = { + year: number + corpusShare: number | null + firstTimeAuthors: number | null + median: number | null + ciLo: number | null + ciHi: number | null + reliable: boolean +} + +function num(s: string | undefined): number | null { + if (s == null) return null + const t = s.trim() + if (t === '' || t.toLowerCase() === 'na' || t.toLowerCase() === 'null') return null + const v = Number(t) + return Number.isFinite(v) ? v : null +} + +function parseCsv(text: string): { provenance: Record; raw: string[]; rows: Row[] } { + const lines = text.split(/\r?\n/) + const provenance: Record = {} + const raw: string[] = [] + const rows: Row[] = [] + let header: string[] | null = null + + for (const line of lines) { + if (!line.trim()) continue + if (line.startsWith('#')) { + raw.push(line) + const m = line.replace(/^#\s*/, '') + const idx = m.indexOf(':') + if (idx > -1) { + const key = m.slice(0, idx).trim().toLowerCase() + provenance[key] = m.slice(idx + 1).trim() + } + continue + } + const cols = line.split(',') + if (!header) { + header = cols.map((c) => c.trim()) + continue + } + const get = (name: string) => cols[header!.indexOf(name)] + const year = num(get('year')) + if (year == null) continue + const reliableRaw = (get('reliable') ?? '').trim().toLowerCase() + rows.push({ + year, + corpusShare: num(get('corpus_share_per_100k')), + firstTimeAuthors: num(get('first_time_authors')), + median: num(get('idea_vintage_median_years')), + ciLo: num(get('vintage_ci_lo')), + ciHi: num(get('vintage_ci_hi')), + reliable: reliableRaw === '1' || reliableRaw === 'true', + }) + } + return { provenance, raw, rows } +} + +/** Loosely find a provenance value by trying several key spellings. */ +function prov(provenance: Record, keys: string[]): string | undefined { + for (const k of Object.keys(provenance)) { + if (keys.some((want) => k.includes(want))) return provenance[k] + } + return undefined +} + +/** Direction of a rising-is-good count series (talent entry). */ +function countDirection(points: { x: number; y: number }[]): Direction { + const slope = trailingSlope(points) + if (slope == null) return 'unclear' + const mean = points.slice(-5).reduce((a, p) => a + p.y, 0) / Math.min(points.length, 5) + const rel = mean > 0 ? (slope * 5) / mean : slope + if (rel > 0.05) return 'accelerating' + if (rel < -0.05) return 'decelerating' + return 'flat' +} + +function toPayload(parsed: ReturnType): OpenAlexArea { + const { provenance, rows } = parsed + const query = prov(provenance, ['query', 'search']) + const generated = prov(provenance, ['generated', 'timestamp', 'date']) + + // idea vintage + const vintageRows = rows.filter((r) => r.median != null) + const series = vintageRows.map((r) => ({ + x: r.year, + y: r.median as number, + lo: r.ciLo ?? undefined, + hi: r.ciHi ?? undefined, + reliable: r.reliable, + })) + const reliableVintage = vintageRows.filter((r) => r.reliable) + const reliablePts = reliableVintage.map((r) => ({ x: r.year, y: r.median as number })) + const latestReliable = reliableVintage.length + ? reliableVintage[reliableVintage.length - 1] + : null + const wm = reliablePts.length >= 2 ? windowMeans(reliablePts, 3) : null + const ideaVintage = series.length + ? { + series, + latest: latestReliable ? { year: latestReliable.year, median: latestReliable.median as number } : null, + early: wm ? { mean: wm.early, from: wm.earlyRange[0] as number, to: wm.earlyRange[1] as number } : null, + recent: wm ? { mean: wm.late, from: wm.lateRange[0] as number, to: wm.lateRange[1] as number } : null, + reliableWindow: reliableVintage.length + ? `${reliableVintage[0].year}\u2013${reliableVintage[reliableVintage.length - 1].year}` + : '\u2014', + direction: ideaVintageDirection(reliablePts) as Direction, + query, + generated, + } + : undefined + + // revealed commitments (talent entry + corpus-share normalizer) + const entrants = rows + .filter((r) => r.firstTimeAuthors != null && r.reliable) + .map((r) => ({ x: r.year, y: r.firstTimeAuthors as number })) + const corpusShare = rows + .filter((r) => r.corpusShare != null && r.reliable) + .map((r) => ({ x: r.year, y: r.corpusShare as number })) + const latestEntrant = entrants.length ? entrants[entrants.length - 1] : null + const revealed = entrants.length + ? { + entrants, + corpusShare, + latest: latestEntrant ? { year: latestEntrant.x, entrants: latestEntrant.y } : null, + window: entrants.length ? `${entrants[0].x}\u2013${entrants[entrants.length - 1].x}` : '\u2014', + direction: countDirection(entrants), + query, + generated, + } + : undefined + + return { ideaVintage, revealed } +} + +function loadArea(area: FocusAreaKey): OpenAlexArea | null { + const file = path.join(DATA_DIR, `${area}.csv`) + let text: string + try { + text = fs.readFileSync(file, 'utf8') + } catch { + return null + } + const parsed = parseCsv(text) + if (!parsed.rows.length) return null + return toPayload(parsed) +} + +export function loadAllOpenAlex(): Partial> { + const out: Partial> = {} + for (const area of AREA_SLUGS) { + const payload = loadArea(area) + if (payload) out[area] = payload + } + return out +}