Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 7 additions & 4 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,13 @@ const nextConfig: NextConfig = {
// 2026-09-30, the edge entry for /api/citable went STALE at age
// 306 and reset to 42, then went STALE again at 313: two clean
// cycles, so the served window is that route's 300 s and not a
// one-off eviction. Change the window in the route, not here. Sitemap's
// Cache-Control is likewise set inside its route.ts
// (`src/app/sitemap.xml/route.ts`) because metadata routes
// ignore next.config headers().
// one-off eviction. Change the window in the route, not here.
// The sitemap sets its Cache-Control the same way and for the same
// reason: `src/app/sitemap.xml/route.ts` is a Route Handler that
// returns its own Response, so whatever it sets wins over this
// layer. (It is a Route Handler rather than a `sitemap.ts`
// metadata route precisely because a metadata route left Next in
// charge of the header; the file's own docblock has that history.)
source: "/api/citable",
headers: [
{
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/aggregate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ export async function GET() {
headers: { "Accept-Encoding": "gzip, br" },
// No cache:"no-store" here: that flag opts the route into
// dynamic mode and causes Next.js to strip s-maxage from our
// response headers, breaking Vercel CDN caching. The ISR
// revalidate=60 above handles freshness instead.
// response headers, breaking Vercel CDN caching. There is no ISR
// revalidate on this route to fall back on (it is force-dynamic,
// line 18); freshness comes from the s-maxage=300 the response
// below sets, which is what the edge honours.
});
} catch (err) {
lastErr = String(err);
Expand Down
13 changes: 8 additions & 5 deletions src/app/api/freshness/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ export const runtime = "nodejs";
* (`scalar(time() - max(timestamp(<metric>)))`), returning just the
* resolved data timestamp per slug.
*
* Separate from /api/citable so the LiveIndicator can poll fast (every
* 8 s) without re-running the heavy spec → rankings → sparkline → series
* pipeline. Edge-cache is short (5 s s-maxage, 20 s swr) so the visible
* staleness lands around 15-20 s p99 - close to the Prom scrape interval
* floor (15 s) that bounds how fresh any client-side query can ever be.
* Separate from /api/citable so the LiveIndicator can poll cheaply
* without re-running the heavy spec → rankings → sparkline → series
* pipeline. It polls every 30 s (live-indicator.tsx) against a 30 s
* s-maxage and 60 s swr, so the visible staleness lands within about a
* minute, above the Prom scrape interval floor (15 s) that bounds how
* fresh any client-side query can ever be. This is the shortest window on
* the site, which is why the widget can read newer than the table beneath
* it.
*/

// Cache window has to stay STRICTLY shorter than the LiveIndicator poll
Expand Down
19 changes: 15 additions & 4 deletions src/app/api/stat/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,21 @@ export async function GET(
// 300 s, not 60: getBenchmark reads through the bench data cache
// (src/lib/spec.ts, revalidate 300), so four of every five fills at
// a 60 s window recomputed this payload and returned the same bytes.
// The two windows do compose, so this is not free: worst-case data
// age goes from about 360 s to about 600 s. That is parity with the
// 600 s ISR on the bench page this mirrors, which is the number a
// reader comparing the two would see anyway.
//
// The two windows compose, and stale-while-revalidate is part of the
// window the edge actually serves: 60 + 300 was a worst case of
// about 360 s, 300 + 900 is about 1200 s, plus the 300 s data cache
// under it. So this is not parity with the 600 s ISR on the bench
// page; it is roughly twice it, and an earlier version of this
// comment claimed parity by counting swr on the old header and not
// on the new one.
//
// Where that shows: /rwa is revalidate = 300 and its FAQ (which
// becomes FAQPage JSON-LD) tells readers this endpoint carries "the
// same values and timestamp" as the page, while advertising it as a
// DataDownload. An agent that follows that link can get a body older
// than the stamp the page showed it. Narrow the window here, or
// soften that sentence there, before treating the two as identical.
"cache-control": "public, s-maxage=300, stale-while-revalidate=900",
"access-control-allow-origin": "*",
},
Expand Down
7 changes: 5 additions & 2 deletions src/app/capital/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import { buildCitationMeta, CREATOR_PUBLISHER, DATASET_LICENSE } from "@/lib/dat
* (TVL, bridged value, stablecoin flows, open interest) and how tokens are
* priced against the fees their protocols earn (P/F, P/S, FDV, float, fee
* trend against token move). Everything is read from the materialized
* benches 273, 275, 274, 265 and 277 plus the two daily history blobs the
* worker publishes; see src/lib/capital-hub.ts.
* benches plus the two daily history blobs the worker publishes. The bench
* list is CAPITAL_BENCHES in src/lib/capital-hub-types.ts, eight of them
* today (273, 274, 275, 265, 277, 271, 280, 281); read it there rather
* than from a copy here, because this copy went stale the first time the
* list grew. See src/lib/capital-hub.ts for how each is used.
*
* The page names numbers and the peers they are compared against. It does
* not tell the reader what to do with them: the neutral wording is the
Expand Down
2 changes: 1 addition & 1 deletion src/components/benchmark-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ export function BenchmarkBody({
// + first render), which the user otherwise eats as a long dimmed
// state after clicking a tab. Warming them right after mount turns
// tab flips into in-memory swaps. Staggered 400ms apart to stay
// gentle; the variant API dedupes across users via its 60s cache,
// gentle; the variant API dedupes across users via its 300s cache,
// and re-runs when the user settles on a new axis value so the
// cross-axis re-warms.
useEffect(() => {
Expand Down
10 changes: 7 additions & 3 deletions src/components/live-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ import { useEffect, useState } from "react";
* Every page that renders this widget has its own ISR cycle, so the SSR
* `lastRunAt` can drift across pages by a few seconds (home was rendered
* at one cache miss, detail at another). To present a single canonical
* value site-wide, when a `slug` is passed we re-fetch /api/citable on
* mount and replace the SSR value with the API value. /api/citable is
* edge-cached for 60 s so the network cost is amortised across viewers.
* value site-wide, when a `slug` is passed we re-fetch /api/freshness on
* mount and replace the SSR value with the API value. That route is
* edge-cached for 30 s (s-maxage=30, stale-while-revalidate=60) so the
* network cost is amortised across viewers. Note the asymmetry: at 30 s
* this widget is the freshest clock on the page, so it can read newer
* than the table under it, whose figures come from the page's own ISR
* window and from per-panel routes on 300 s.
*/
export function LiveIndicator({
lastRunAt,
Expand Down
12 changes: 9 additions & 3 deletions src/components/site-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@ import { SITE } from "@/data/site";
* What did NOT change is the link set. None of these ten appear in the
* left rail: the rail carries the editorial sections (/benchmarks,
* /perps, /rpc and so on) and this carries the developer and project
* surfaces. /llms.txt and /api/citable are not in the sitemap either, so
* this footer is their only inbound link and dropping it would orphan the
* two files the whole citability effort exists to serve.
* surfaces.
*
* Before pruning this row, check the sitemap. FOUR of these ten are
* absent from it, so for each of them this footer is the only inbound
* link on the site: /llms.txt, /api/citable, /api/openapi.json and
* /api/llm-context. Dropping any one orphans a file the citability work
* exists to serve. (/partners, /badges, /team and /press are in the
* sitemap and would survive a cut here.) An earlier version of this note
* named only the first two, which read as a complete list and was not.
*/
const DEVELOPER_LINKS = [
{ label: "Partners + embeds", href: "/partners" },
Expand Down
31 changes: 7 additions & 24 deletions src/lib/capital-hub-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import {
SIGNAL_READING,
bridgedShareSubline,
cctpScope,
change7dFromDays,
change7dFromSeries,
categoryMedianUsable,
change7dOfSeries,
cohortCell,
Expand Down Expand Up @@ -217,39 +215,24 @@ describe("divergences", () => {
});

describe("7d change", () => {
test("from daily history: only once the day seven days back exists and the newest day is current", () => {
const days = [
{ day: "2026-09-18", oi: 100 },
{ day: "2026-09-19", oi: 105 },
{ day: "2026-09-25", oi: 120 },
];
const now = Date.parse("2026-09-25T18:00:00Z");
expect(change7dFromDays(days, "oi", now)).toBeCloseTo(20, 6);
// Yesterday's point is still current; a point three days old is a stalled worker.
expect(change7dFromDays(days, "oi", Date.parse("2026-09-26T23:00:00Z"))).toBeCloseTo(20, 6);
expect(change7dFromDays(days, "oi", Date.parse("2026-09-28T01:00:00Z"))).toBeNull();
expect(change7dFromDays(days.slice(1), "oi", now)).toBeNull();
expect(change7dFromDays([], "oi", now)).toBeNull();
expect(change7dFromDays([{ day: "2026-09-25", oi: 120 }], "oi", now)).toBeNull();
});
test("from a bench series: first and last finite buckets, and only when the series covers the whole window", () => {
const covered = [100, null, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200];
expect(change7dFromSeries(covered)).toBeCloseTo(100, 6);
expect(change7dOfSeries(covered).value).toBeCloseTo(100, 6);
// A bench that started this week: the first finite bucket sits past the first tenth of the window.
const late = [null, null, null, null, null, null, 150, 160, 170, 180, 190, 200];
expect(change7dFromSeries(late)).toBeNull();
expect(change7dOfSeries(late).value).toBeNull();
// About 75% coverage is not seven days either.
const threeQuarters = Array.from({ length: 84 }, (_, i) => (i < 21 ? null : 100 + i));
expect(change7dFromSeries(threeQuarters)).toBeNull();
expect(change7dOfSeries(threeQuarters).value).toBeNull();
// A step of more than 2x between two adjacent buckets is a change in what the bench
// measures (Kalshi's open interest went from $71M to $1.02B in one bucket this week): no 7d figure.
const step = Array.from({ length: 84 }, (_, i) => (i < 40 ? 71_000_000 + i * 10_000 : 1_010_000_000 + i * 10_000));
expect(change7dFromSeries(step)).toBeNull();
expect(change7dOfSeries(step).value).toBeNull();
// A large but continuous move stays: +80% over the week in small increments.
const steady = Array.from({ length: 84 }, (_, i) => 100 * (1 + (0.8 * i) / 83));
expect(change7dFromSeries(steady)).toBeCloseTo(80, 6);
expect(change7dFromSeries(undefined)).toBeNull();
expect(change7dFromSeries([null, null])).toBeNull();
expect(change7dOfSeries(steady).value).toBeCloseTo(80, 6);
expect(change7dOfSeries(undefined).value).toBeNull();
expect(change7dOfSeries([null, null]).value).toBeNull();
});
test("an empty 7d cell says which condition failed, so the table never prints a bare dash for a known reason", () => {
expect(change7dOfSeries(undefined).naReason).toBe(NA_REASON.oiSeriesMissing);
Expand Down
49 changes: 9 additions & 40 deletions src/lib/capital-hub-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ export function bridgedShareSubline(sharePct: number | null): string | null {
* withheld row, a bench that failed to load this render). The cell
* prints "n/a" and nothing else.
*
* "n/a" is the missing value here because it is the missing value on the
* other 37 places the site uses it (perp head to head, the trading-app
* charts, the RPC tables, the fee comparison), and a token cannot mean two
* things on two pages. The inapplicable case gets its own rendering instead,
* as it already does on the fee comparison page.
* "n/a" is the missing value here because it is the missing value
* everywhere else on the site (perp head to head, the trading-app charts,
* the RPC tables, the fee comparison), and a token cannot mean two things
* on two pages. The inapplicable case gets its own rendering instead, as it
* already does on the fee comparison page.
*
* This is the bench 208 rule applied to a table: an absent measurement must
* not read as a measured absence.
Expand Down Expand Up @@ -127,11 +127,9 @@ export const NA_REASON = {
oiSeriesShort: "the 7-day window is not covered: the series starts inside it",
oiSeriesFlat: "fewer than two readings in the 7-day window",
oiSeriesStep: "the series steps more than 2x inside the window: a change in what the venue reports, not a 7-day move",
categoryTooSmall: "category too small for a peer median: under five ranked protocols, the ratio compares the token with one or two others",
categoryTooSmall: "category too small for a peer median: under five ranked protocols, the ratio compares the token with at most three others",
} as const;

export type NaReason = (typeof NA_REASON)[keyof typeof NA_REASON];

export function cohortCell(
inCohort: boolean,
ranked: number | null,
Expand Down Expand Up @@ -315,31 +313,6 @@ export function selectDivergences<T extends DivergenceCandidate>(rows: T[], limi

/* ------------------------------------------------------- 7d change */

/** A daily history counts as current while its newest day is today or yesterday by the clock (UTC), not by the blob's own stamp. */
export const HISTORY_FRESH_MS = 2 * 86_400_000;

/**
* Percent change between the newest point and the point seven days before
* it in a daily history (the valuation blob's `oi` per perp). Null until
* the blob holds that older day, and null when the newest day is older
* than `nowMs` minus two days: no shorter window is passed off as 7d and
* a stalled worker publishes no change.
*/
export function change7dFromDays(days: { day: string; [k: string]: unknown }[], field: string, nowMs: number = Date.now()): number | null {
if (days.length === 0) return null;
const last = days[days.length - 1];
const lastV = last[field];
if (typeof lastV !== "number" || !Number.isFinite(lastV)) return null;
const t = Date.parse(`${last.day}T00:00:00Z`);
if (!Number.isFinite(t)) return null;
if (nowMs - t > HISTORY_FRESH_MS) return null;
const target = new Date(t - 7 * 86_400_000).toISOString().slice(0, 10);
const before = days.find((d) => d.day === target);
const beforeV = before?.[field];
if (typeof beforeV !== "number" || !Number.isFinite(beforeV) || beforeV <= 0) return null;
return ((lastV - beforeV) / beforeV) * 100;
}

/** Largest bucket-to-bucket ratio a 7d series may carry and still read as one continuous window. */
export const SERIES_STEP_MAX = 2;

Expand Down Expand Up @@ -381,11 +354,6 @@ export function change7dOfSeries(series: (number | null)[] | undefined): { value
return { value: ((b - a) / a) * 100, naReason: null };
}

/** The number only, for callers that already know why an empty cell is empty. */
export function change7dFromSeries(series: (number | null)[] | undefined): number | null {
return change7dOfSeries(series).value;
}

/* --------------------------------------------------- reading notes */

/**
Expand Down Expand Up @@ -536,8 +504,9 @@ export function signalRowNote(r: {
* Whether an optional column is worth a place in the table: at least half the
* rows carry a value. A column filled for two rows of seventy-nine paints the
* table with empty cells and says nothing (the owner's note on the first hub
* draft), so a source still filling in, or one a rate limit keeps mostly
* empty, stays hidden until it covers the cohort.
* draft, whose chain cohort was seventy-nine rows; it is 63 today), so a
* source still filling in, or one a rate limit keeps mostly empty, stays
* hidden until it covers the cohort.
*
* Every optional column of the chains table goes through this, on the value
* that would actually print (`printedValue`), so a column like net USDC over
Expand Down
18 changes: 13 additions & 5 deletions src/lib/capital-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,25 @@
* bench pages render) plus the two daily history blobs the worker writes
* (src/lib/capital-history.ts). No Prometheus call at render time.
*
* Two cohorts:
* Two cohorts (the full bench list is CAPITAL_BENCHES in
* src/lib/capital-hub-types.ts, eight entries):
* - follow the capital: chains, from bench 273 chain-bridged-tvl (L2Beat
* value secured), bench 275 chain-stablecoin-flow (DeFiLlama stablecoin
* float per chain) and the chains history blob (TVL, native mcap, DEX
* volume, chain fees and revenue when the chain-kpis harness publishes
* them); open interest from bench 265 (perp DEX OI) and bench 277
* (prediction markets).
* float per chain), bench 280 chain-fees-revenue, bench 281
* usdc-corridor-flows (net USDC over CCTP) and the chains history blob
* (TVL, native mcap, DEX volume, chain fees and revenue when the
* chain-kpis harness publishes them); open interest from the perp venue
* cohort gauges with turnover from bench 271 perp-volume-oi-ratio, and
* from bench 277 pm-open-interest for prediction markets.
* - valuation: bench 274 protocol-pf-ratio (cross-DeFi P/F, fees MoM,
* token 30d, category median) and bench 265 perp-pf-ratio (P/F, P/S,
* FDV, float, OI, fees and revenue).
*
* Perp open interest is NOT bench 265's `oi` panel. It was until
* 2026-09-30; the block above perpOi below says why it moved and what
* disagreed. Bench 265 is named once here, as perp-pf-ratio, and that is
* the only thing it feeds.
*
* Every field is independently nullable, and an empty cell says which kind
* of empty it is: a column that does not apply to the row carries the
* reason why (`notApplicable`, from the cohort flags the benches publish),
Expand Down
26 changes: 13 additions & 13 deletions src/lib/spec-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,19 +722,6 @@ export const SpecSchema = z
.max(17)
.optional(),

/**
* Optional relabeling of the ledger's aggregate columns. For benches
* whose unit has no percentile semantics (USD revenue leaderboards),
* the p50/p90/p99/mean slots are repurposed; declaring ledger_columns
* renders each column with an honest label and unit instead of the
* default latency headers. `slot` reads the provider's headline slot,
* `panel` reads the values of a metric_panels entry by id. The first
* column is the headline (sort key, data bar, mobile column).
*/
/** false when the providers' `success` query is a publication gate
* (0 or 1) rather than a probe success rate: the ledger then hides
* the Success and Reliability columns and describes an unpublished
* row as under its threshold, not as a dead endpoint. */
/**
* Whether 0 is a real value on this bench rather than a missing one.
*
Expand All @@ -747,7 +734,20 @@ export const SpecSchema = z
* Defaults false, so every existing bench keeps the behaviour it has.
*/
zero_is_a_value: z.boolean().default(false),
/** false when the providers' `success` query is a publication gate
* (0 or 1) rather than a probe success rate: the ledger then hides
* the Success and Reliability columns and describes an unpublished
* row as under its threshold, not as a dead endpoint. */
ledger_reliability: z.boolean().default(true),
/**
* Optional relabeling of the ledger's aggregate columns. For benches
* whose unit has no percentile semantics (USD revenue leaderboards),
* the p50/p90/p99/mean slots are repurposed; declaring ledger_columns
* renders each column with an honest label and unit instead of the
* default latency headers. `slot` reads the provider's headline slot,
* `panel` reads the values of a metric_panels entry by id. The first
* column is the headline (sort key, data bar, mobile column).
*/
ledger_columns: z
.array(
z
Expand Down
Loading
Loading