Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
8153cac
Add cross-focus-area impact dashboard
Jun 29, 2026
c0df232
Refine impact dashboard copy and logic-model alignment
Jun 29, 2026
4379aba
feat(impact): multi-role PL pills + bottleneck-based role taxonomy
Jun 30, 2026
b5ab011
fix(impact): drop per-role descriptions from detail modal
Jun 30, 2026
81148d5
fix(impact): swap Q1/Q2 (Q1=did it matter/impact, Q2=did it happen/ou…
Jun 30, 2026
f8c6115
fix(impact): real hover tooltip on PL role pills
Jun 30, 2026
0446181
impact(redesign): two-axis color tokens + role/logic-model copy
Jul 1, 2026
b998abd
impact(redesign): cards use THE FIELD / OUR HAND split + live signal
Jul 1, 2026
7280622
impact(redesign): measuring A/B/C cards + color-coded chain + toolkit…
Jul 1, 2026
81e19b1
impact(redesign): review pass
Jul 1, 2026
37fa01a
impact: fold in redesign + detail/measuring refinements
Jul 1, 2026
543d831
impact: methodology jump link, black field color, roles-as-modal, copy
Jul 1, 2026
49d9823
impact: simplify methodology to three questions
Jul 1, 2026
5ebaa0e
impact: make the whole 'What difference are we making?' card clickable
Jul 1, 2026
6fa04c4
impact: link detail cards to focus-area insights + measuring copy
Jul 1, 2026
b9c80bd
prototype(impact): crowd-forecast signals on inflection points
Jul 6, 2026
6275785
impact: move crowd forecast into the horizontal Live-signal band
Jul 6, 2026
857a2c7
feat(impact): show market volume (money at stake) in Live signal band
Jul 6, 2026
19e0c57
feat(impact): restructure detail modal + fold in market signals
Jul 6, 2026
211a813
refactor(impact): reorder field, separate sections, restore PL role p…
Jul 6, 2026
879a9d6
impact: put Our hand & Live signal on grey cards like The field
Jul 6, 2026
7536455
impact: hide live-signal detail in preview; add Provenance signals
Jul 6, 2026
cb790e4
impact: move Turkey Wikipedia-on-IPFS signal to 'Communication that c…
Jul 6, 2026
ab31999
impact: make two-axis accents dark-mode legible
Jul 6, 2026
218ffb2
impact: drop GainForest/Glow Q3 evidence line from outcomes-verified …
Jul 7, 2026
5106794
radar: auto-advance slides with story-style progress bars
Jul 7, 2026
5b4d38f
Add named Capital-role ventures as contribution outputs
claude Jul 7, 2026
3f3643a
Linkify named ventures in contribution copy; drop hedge text
claude Jul 7, 2026
e675ced
Fix inflection point card text: 'How PL is making a difference'
Jul 7, 2026
42fb68e
impact: relabel field subtitle to 'Inflection point status and impact'
Jul 7, 2026
6ff8f73
impact: add "How to read this" overlay + methodology callout, mark fo…
lksbrssr Jul 7, 2026
1e3666d
impact: drop the "How to read this" methodology pull-quote
lksbrssr Jul 7, 2026
fbe11d6
impact: retune crowd-forecast markets
lksbrssr Jul 7, 2026
c202710
impact: reassign Metaculus WBE forecasts + drop World from programmab…
lksbrssr Jul 7, 2026
08cf753
Merge remote-tracking branch 'origin/main' into lukas/impact-dashboard
Jul 14, 2026
71e126d
impact: rename 'Capital' role to 'Funding' in methodology modal
Jul 14, 2026
c5dc6c0
impact-v2: add acceleration-centered data model (field-velocity.ts)
lksbrssr Jul 29, 2026
39cb45f
impact-v2: add ImpactDashboardV2 (velocity basket + sensors + our hand)
lksbrssr Jul 29, 2026
45171d1
impact-v2: add MeasuringQuestionsV2 methodology section
lksbrssr Jul 29, 2026
74e2f8a
impact-v2: add /impact-v2 route (reframe hero + cross-link to /impact)
lksbrssr Jul 29, 2026
58e1a71
impact-v2: add side-by-side screenshots (both dashboards, light + dark)
lksbrssr Jul 29, 2026
8c2b1af
Merge latest main into impact-v2 (sync site chrome + Insights/radar u…
lksbrssr Jul 29, 2026
db79887
impact: single field-velocity dashboard at /impact
Aug 3, 2026
3077c49
impact: inflection cards + wide field-velocity box; velocity metrics …
Aug 3, 2026
e1c5b4f
impact: vertical tabs (PR #29 layout); tie velocity and methodology t…
Aug 3, 2026
db78cdf
impact: drop draft framing; sticky vertical tabs; velocity label outs…
Aug 3, 2026
48eebfa
impact: single-source velocity instruments with per-field states + ch…
Aug 3, 2026
e5c8872
impact: inflection points carry outcome x mattered (Task 4)
Aug 3, 2026
9c3774f
impact: wire idea vintage (+ talent entry) from OpenAlex CSVs
Aug 3, 2026
11e43d8
impact: markets live from mapped forecasts, card/copy polish, real Op…
Aug 3, 2026
a9a7749
impact(data): real OpenAlex idea-vintage CSVs for all four focus areas
Aug 3, 2026
a68c077
impact: idea-vintage axis + start->latest comparison; explain in meth…
Aug 3, 2026
8c96dd4
impact(fix): idea-vintage direction must agree with the numbers shown
Aug 3, 2026
ab86daf
impact(methodology): explain how the idea-vintage sparkline is built …
Aug 3, 2026
11de8b8
impact(copy): name OpenAlex in the idea-vintage modal and convey care…
Aug 3, 2026
1c8ab38
impact: remove the maturity progress bar from the modal and cards
Aug 3, 2026
75b89d4
impact: fold an attribution note into the interventions block
Aug 3, 2026
3dc1426
Make impact dashboard an unlisted, non-indexed preview route
lksbrssr Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
23 changes: 23 additions & 0 deletions docs/screenshots/README-impact-v2.md
Original file line number Diff line number Diff line change
@@ -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 |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/impact-modal-restructured.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/impact-signals-grid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/impact-signals-livebar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/impact-signals-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/impact-signals-platforms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/inflection-card-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/inflection-card-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v1-dark-cards.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v1-dark-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v1-light-cards.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v1-light-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v2-dark-cards.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v2-dark-sensor-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v2-dark-sensors.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v2-dark-toolkit-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v2-dark-toolkit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v2-light-cards.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v2-light-howtoread-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v2-light-sensor-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/v2-light-sensors.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/velocity-box-openalex-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/velocity-box-openalex-light.png
Binary file added docs/screenshots/velocity-modal-dark.png
Binary file added docs/screenshots/velocity-modal-light.png
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions scripts/velocity/README.md
Original file line number Diff line number Diff line change
@@ -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`).
248 changes: 248 additions & 0 deletions scripts/velocity/field_velocity_openalex.py
Original file line number Diff line number Diff line change
@@ -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()
64 changes: 64 additions & 0 deletions scripts/velocity/vintage-direction.mjs
Original file line number Diff line number Diff line change
@@ -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
}
Loading