diff --git a/app/scaling/[area]/page.tsx b/app/scaling/[area]/page.tsx index fe39e7b..2fc1cf3 100644 --- a/app/scaling/[area]/page.tsx +++ b/app/scaling/[area]/page.tsx @@ -24,12 +24,12 @@ export default async function ScalingAreaPage({ params }: { params: Promise<{ ar const ceilings = withDaWorkloads(snapshot.ceilings); return (
-
+
Ceiling · Mgas/s - +
- {area === 'da' ? : null} + {area === 'da' ? : null} {section ? ( ) : ( diff --git a/app/scaling/archiveSize.test.ts b/app/scaling/archiveSize.test.ts new file mode 100644 index 0000000..c160c4c --- /dev/null +++ b/app/scaling/archiveSize.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs'; + +import { afterEach, expect, it, vi } from 'vitest'; + +import { ARCHIVE_SNAPSHOTS_URL, fetchArchiveSize, loadArchiveSize, parseArchiveSize, withArchiveSize } from './archiveSize'; +import type { Snapshot } from './snapshot.mjs'; + +// Synthetic sizes in the chain.base.org shape, with extra fields the parser must ignore. +const ENTRIES = [ + { network: 'sepolia', profile: 'archive', block: 9, date: '2026-01-01', size: 1e12, components: [] }, + { + chainId: '8453', network: 'mainnet', profile: 'archive', block: 1_234_567, timestamp: '1767225600', date: '2026-01-01', size: 6e12, isModular: true, + components: [{ name: 'state', displayName: 'State', size: 1e12 }, { name: 'proofs', size: 1.5e12 }, { name: 'receipts', size: 2e12, fullSize: 1e11 }], + }, +]; +const ARCHIVE = { size: 6e12, sizeWithoutProofs: 4.5e12, block: 1_234_567, date: '2026-01-01', source: 'chain.base.org' }; +const fixture = (network: string): Snapshot => ({ ...JSON.parse(readFileSync(new URL('./fixtures/synthetic.v2.json', import.meta.url), 'utf8')), network }); + +afterEach(() => vi.restoreAllMocks()); + +it('reads the mainnet archive entry, total including proofs and without them', () => { + expect(parseArchiveSize(ENTRIES)).toEqual(ARCHIVE); +}); + +it('rejects a response that is not the expected shape', () => { + const mainnet = ENTRIES[1]; + for (const bad of [ + {}, + [ENTRIES[0]], + [{ ...mainnet, size: '6e12' }], + [{ ...mainnet, size: 0 }], + [{ ...mainnet, block: 1.5 }], + [{ ...mainnet, date: 'yesterday' }], + [{ ...mainnet, date: '2026-02-30' }], + [{ ...mainnet, components: null }], + [{ ...mainnet, components: [{ name: 'proofs', size: -1 }] }], + [{ ...mainnet, components: [{ name: 'proofs', size: 7e12 }] }], + ]) expect(parseArchiveSize(bad)).toBeNull(); +}); + +it('fetches with a timeout and throws on a failed or malformed response', async () => { + const fetchImpl = vi.fn(async () => Response.json(ENTRIES)); + expect(await fetchArchiveSize(fetchImpl as unknown as typeof fetch)).toEqual(ARCHIVE); + expect(fetchImpl).toHaveBeenCalledWith(ARCHIVE_SNAPSHOTS_URL, { signal: expect.any(AbortSignal) }); + await expect(fetchArchiveSize((async () => new Response('down', { status: 502 })) as typeof fetch)).rejects.toThrow('502'); + await expect(fetchArchiveSize((async () => Response.json([{ network: 'mainnet' }])) as typeof fetch)).rejects.toThrow('unexpected response shape'); +}); + +it('falls back to null on a timeout, logging why', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const timeout = () => Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError')); + expect(await loadArchiveSize(timeout)).toBeNull(); + expect(warn).toHaveBeenCalledOnce(); + expect(await loadArchiveSize(async () => ARCHIVE as never)).toEqual(ARCHIVE); +}); + +it('replaces a mainnet snapshot’s archive size and leaves others or a failed fetch alone', () => { + const mainnet = fixture('Base mainnet'); + expect(withArchiveSize(mainnet, ARCHIVE as never).production!.archiveBytes).toEqual({ + value: 6e12, asOf: '2026-01-01', source: 'chain.base.org snapshot, block 1,234,567', note: 'incl. proofs', + }); + expect(withArchiveSize(mainnet, ARCHIVE as never).production!.stateBytesPerGas).toEqual(mainnet.production!.stateBytesPerGas); + expect(withArchiveSize(mainnet, null)).toBe(mainnet); + const synthetic = fixture('synthetic-testnet'); + expect(withArchiveSize(synthetic, ARCHIVE as never)).toBe(synthetic); +}); diff --git a/app/scaling/archiveSize.ts b/app/scaling/archiveSize.ts new file mode 100644 index 0000000..a0eb202 --- /dev/null +++ b/app/scaling/archiveSize.ts @@ -0,0 +1,63 @@ +import { unstable_cache } from 'next/cache'; + +import type { Snapshot } from './snapshot.mjs'; + +// chain.base.org lists the latest node snapshots publicly. It sends no CORS header, so the server fetches it. +export const ARCHIVE_SNAPSHOTS_URL = 'https://chain.base.org/api/snapshots'; +const TIMEOUT_MS = 5_000; +const REVALIDATE_SECONDS = 3_600; + +// `size` is the whole archive snapshot, proofs included: production nodes run archive with proofs. +export type ArchiveSize = { size: number; sizeWithoutProofs: number; block: number; date: string; source: 'chain.base.org' }; + +const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); +const isDay = (date: string) => !Number.isNaN(Date.parse(date)) && new Date(date).toISOString().slice(0, 10) === date; +const isSize = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0; + +// The mainnet archive entry, checked field by field; extra fields are ignored. Null when the shape is off. +export function parseArchiveSize(json: unknown): ArchiveSize | null { + if (!Array.isArray(json)) return null; + const entry = json.find((e) => isObj(e) && e.network === 'mainnet' && e.profile === 'archive'); + if (!isObj(entry)) return null; + const { size, block, date, components } = entry; + if (!isSize(size) || size === 0 || !Number.isSafeInteger(block) || (block as number) <= 0) return null; + // A real calendar day: 2026-02-30 matches the pattern but is not one. + if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !isDay(date) || !Array.isArray(components)) return null; + if (!components.every((c) => isObj(c) && typeof c.name === 'string' && isSize(c.size))) return null; + const proofs = (components as { name: string; size: number }[]).find((c) => c.name === 'proofs')?.size ?? 0; + if (proofs > size) return null; + return { size, sizeWithoutProofs: size - proofs, block: block as number, date, source: 'chain.base.org' }; +} + +// Throws on any failure so a failed fetch is not cached. +export async function fetchArchiveSize(fetchImpl: typeof fetch = fetch): Promise { + const res = await fetchImpl(ARCHIVE_SNAPSHOTS_URL, { signal: AbortSignal.timeout(TIMEOUT_MS) }); + if (!res.ok) throw new Error(`${ARCHIVE_SNAPSHOTS_URL}: ${res.status}`); + const archive = parseArchiveSize(await res.json()); + if (!archive) throw new Error(`${ARCHIVE_SNAPSHOTS_URL}: unexpected response shape`); + return archive; +} + +const fetchArchiveSizeCached = unstable_cache(() => fetchArchiveSize(), ['scaling-archive-size'], { revalidate: REVALIDATE_SECONDS }); + +// Optional context: on failure the page keeps the snapshot's archive size. +export async function loadArchiveSize(load: () => Promise = fetchArchiveSizeCached): Promise { + try { + return await load(); + } catch (error) { + console.warn('[scaling] archive size from chain.base.org unavailable, using the snapshot value:', error); + return null; + } +} + +// Only mainnet snapshots take the live mainnet archive size; testnet and synthetic ones keep their own. +export function withArchiveSize(snapshot: Snapshot, archive: ArchiveSize | null): Snapshot { + if (!archive || !/\bmainnet\b/i.test(snapshot.network)) return snapshot; + return { + ...snapshot, + production: { + ...snapshot.production, + archiveBytes: { value: archive.size, asOf: archive.date, source: `chain.base.org snapshot, block ${archive.block.toLocaleString('en-US')}`, note: 'incl. proofs' }, + }, + }; +} diff --git a/app/scaling/components/Bottlenecks.tsx b/app/scaling/components/Bottlenecks.tsx new file mode 100644 index 0000000..fb8af1c --- /dev/null +++ b/app/scaling/components/Bottlenecks.tsx @@ -0,0 +1,327 @@ +import { Card } from '../../components/ui/Card'; +import { cn } from '../../components/ui/cn'; +import { Text } from '../../components/ui/Text'; +import { BLOB_DATA_BYTES, BLOB_TARGET, daCapacity, fmt, L1_SLOT_SECONDS, type DaWorkload } from '../da/workloads'; +import { runHref, successfulTps, type PipelineStep, type Proven, type ProvenCapacity, type Run, type Storage, type Workload } from '../proven/capacity'; +import { EXECUTE_PAYLOAD_P99_MS, PROOF_LAG_P99_BLOCKS, REPLAY_BLOCKS_PER_SECOND } from './Guardrails'; +import { Info } from './Info'; +import { day } from './ProvenCapacity'; +import type { Snapshot } from '../snapshot.mjs'; +import { Tag } from './Tag'; + +export type Bar = { id: string; label: string; value: number | null; text: string; tag?: DaWorkload['tag']; perSecond?: number }; +// One row of the ranked table: a bar, or execution, validators and proof node merged when they share a ceiling. +export type Limit = { + id: string; + label: string; + value: number | null; + ceiling: string; + evidence: string; + tag?: DaWorkload['tag']; + perSecond?: number; + workload?: DaWorkload; + help?: string; + // Help beside the ceiling itself. + ceilingHelp?: string; + runwayYears?: number; +}; + +export const times = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}×`; +const TH = 'px-3 py-2 text-left text-[11px] font-[600] uppercase tracking-wide text-bds-gray-60'; +const TD = 'px-3 py-2.5 align-middle'; +const STEP_DOT: Record = { passed: 'bg-bds-green-50', failed: 'bg-bds-red-50', skipped: 'bg-bds-gray-30', 'not-implemented': 'bg-bds-gray-30' }; + +// Validators and the proof node within budget at the proven rate have a limit of at least that rate; +// over budget, their limit is somewhere below it. +export function bottleneckBars(workloads: DaWorkload[], proven: Proven | null): Bar[] { + const da = workloads.map((w): Bar => { + const { mgasPerSecond: value, txPerSecond } = daCapacity(w, BLOB_TARGET); + return { id: `da-${w.payload}`, label: `DA · ${w.label}`, value, text: `${w.tag === 'estimated' ? '~' : ''}${fmt(value)}`, tag: w.tag, perSecond: txPerSecond }; + }); + const run = proven?.run; + const m = proven?.mgas ?? null; + const proofs = run?.proofNode?.proofs; + const replay = run?.replay; + const executeP99 = proofs?.methods.debug_executePayload?.latency.p99Ms; + const proofOk = proofs && executeP99 !== undefined && proofs.headLagBlocks.p99 <= PROOF_LAG_P99_BLOCKS && executeP99 <= EXECUTE_PAYLOAD_P99_MS; + return [ + ...da, + { id: 'execution', label: 'Execution (sequencer)', value: m, text: !proven ? 'no proven run' : proven.atTop ? `≥ ${m} · top rate tested` : `${m}` }, + !replay + ? { id: 'validators', label: 'Validators', value: null, text: 'pending' } + : replay.match && replay.blocksPerSecond >= REPLAY_BLOCKS_PER_SECOND + ? { id: 'validators', label: 'Validators', value: m, text: `≥ ${m} · ${times(replay.blocksPerSecond / REPLAY_BLOCKS_PER_SECOND)} replay headroom` } + : { id: 'validators', label: 'Validators', value: null, text: `below budget at ${m}` }, + !proofs + ? { id: 'proof-node', label: 'Proof node', value: null, text: 'pending' } + : { id: 'proof-node', label: 'Proof node', value: proofOk ? m : null, text: `${proofOk ? `≥ ${m}` : `over budget at ${m}`} · lag p99 ${proofs.headLagBlocks.p99} blocks` }, + ]; +} + +const TRAFFIC: Record = { payment: 'payments', swap: 'swaps', mix: 'transactions' }; +const blobRule = (target: number) => + `The ceiling is the gas rate that fills the ${target}-blob target: ${target} × ${fmt(BLOB_DATA_BYTES)} bytes per blob ÷ ${L1_SLOT_SECONDS} s per slot ÷ bytes per gas.`; + +// Evidence and help for a DA row. `first` marks the lowest DA row, where the shared target is named. +function daText(w: DaWorkload, first: boolean): { evidence: string; help: string } { + const shared = first ? ', shared by all rollups' : ''; + const bench = w.bench && w.source?.url === runHref(w.bench.run) ? w.bench : null; + if (bench) { + const { run, da } = bench; + const target = da.target ?? BLOB_TARGET; + const windowMgas = da.result.gas_per_second / 1e6; + // The DA run reports blobs over its posting window, which runs below the load rate; rescale to the load. + const blobs = Math.round((da.result.blobs_per_l1_slot * run.mgas) / windowMgas); + const reverted = run.reverted > 0.01 ? ` ${Math.round(run.reverted * 100)}% of ${TRAFFIC[w.payload]} reverted.` : ''; + return { + evidence: `Measured in the bench: at ${run.mgas} Mgas/s, needs ${blobs} blobs per L1 slot. Target is ${target}${shared}.${reverted}`, + help: `Run ${run.outputDir}: ${da.result.bytes_per_gas.toPrecision(3)} compressed bytes per gas (compression ${da.result.compression_ratio.toFixed(1)}×). Over the batch posting window it averaged ${da.result.blobs_per_l1_slot.toFixed(1)} blobs per L1 slot at ${windowMgas.toFixed(1)} Mgas/s; scaling that window to the ${run.mgas} Mgas/s load gives ${blobs}. ${blobRule(target)}`, + }; + } + // Without a bench DA run every /scaling workload, the mix included, is an estimate. + return { + evidence: `Estimated: no bench DA run yet, so this uses the formula. Target is ${BLOB_TARGET}${shared}.`, + help: `Estimated ${fmt(w.bytesPerTx)} compressed bytes and ${fmt(w.gasPerTx)} gas per transaction. ${blobRule(BLOB_TARGET)}`, + }; +} + +function coreHelp(run: Run): string { + const replay = run.replay; + const proofs = run.proofNode?.proofs; + const execute = proofs?.methods.debug_executePayload?.latency.p99Ms; + return [ + `Run ${run.outputDir}.`, + replay && `Validator replay: ${replay.blocksPerSecond.toFixed(1)} blocks/s, ${times(replay.blocksPerSecond / REPLAY_BLOCKS_PER_SECOND)} the ${REPLAY_BLOCKS_PER_SECOND} needed.`, + proofs && `Proof node: lag p99 ${proofs.headLagBlocks.p99} blocks${execute === undefined ? '' : `, executePayload p99 ${Math.round(execute)} ms`}.`, + ].filter(Boolean).join(' '); +} + +// Lowest ceiling first; limits without a ceiling keep their order after the ranked ones. +// `mainnetGasPerTx` (today's usage p50 ÷ tx/s) converts every row not specific to one workload, as the traffic tile does. +export function rankLimits(bars: Bar[], workloads: DaWorkload[], proven: Proven | null, disk: Limit[] = [], mainnetGasPerTx: number | null = null): Limit[] { + const m = proven?.mgas ?? null; + const text = new Map { evidence: string; help: string }>(); + const da = bars.filter((b) => b.id.startsWith('da-')).map((b): Limit => { + const w = workloads.find((x) => `da-${x.payload}` === b.id)!; + text.set(b.id, (first) => daText(w, first)); + // Payments and swaps convert with their own transactions; the mix, like every shared row, at today's mainnet average below. + const mix = b.id === 'da-mix'; + const perSecond = mix ? undefined : b.perSecond; + const bench = w.bench ? ` (the bench mix${w.gasPerTx < mainnetGasPerTx! ? ' is lighter' : ''}: ~${Math.round(w.gasPerTx / 1e3)}k)` : ''; + const ceilingHelp = mix && mainnetGasPerTx ? `tx/s at today's mainnet average of ~${Math.round(mainnetGasPerTx! / 1e3)}k gas per transaction${bench}.` : undefined; + return { id: b.id, label: b.label, value: b.value, ceiling: b.text, evidence: '', tag: b.tag, perSecond, workload: w, ceilingHelp }; + }); + const core = bars.filter((b) => !b.id.startsWith('da-')); + const atLeast = (atTop: boolean) => (atTop ? `≥ ${m}` : `${m}`); + const merged: Limit[] = m !== null && core.every((b) => b.value === m) + ? [{ + id: 'core', + label: 'Execution, validators, proof node', + value: m, + ceiling: atLeast(proven!.atTop), + evidence: `Measured in the bench: all passed at ${m} Mgas/s${proven!.atTop ? ', the highest rate tested.' : '; the next rate tested did not pass.'}`, + help: coreHelp(proven!.run), + }] + : core.map((b) => ({ + id: b.id, + label: b.label, + value: b.value, + ceiling: b.value === null ? '—' : atLeast(b.id !== 'execution' || proven!.atTop), + evidence: b.value === null ? b.text : b.id === 'execution' && !proven!.atTop ? 'the next rate tested did not pass' : b.text.split(' · ').slice(1).join(' · '), + })); + const sorted = [...da, ...merged, ...disk].toSorted((a, b) => (a.value ?? Infinity) - (b.value ?? Infinity)); + const firstDa = sorted.find((l) => l.id.startsWith('da-'))?.id; + const atMainnet = (l: Limit) => l.perSecond === undefined && mainnetGasPerTx !== null && l.value !== null && l.value > 0 ? { perSecond: (l.value * 1e6) / mainnetGasPerTx } : {}; + return sorted.map((l) => ({ ...l, ...atMainnet(l), ...(text.has(l.id) ? text.get(l.id)!(l.id === firstDa) : {}) })); +} + +export type StorageSource = { storage: Storage; run: Run }; + +export const bytes = (n: number) => (n >= 1e12 ? `${(n / 1e12).toFixed(1)} TB` : `${(n / 1e9).toFixed(1)} GB`); +const WORKLOAD: Record = { mix: 'mainnet mix', payment: 'payments', swap: 'swaps' }; + +// Storage data from the run with the heaviest transactions (most gas per successful transaction); ties go to the newest. +// Lighter bench transactions than mainnet's overstate history bytes per gas. +export function pickStorage(data: ProvenCapacity | undefined): StorageSource | null { + if (!data) return null; + const { current, da } = data; + const gasPerTx = (r: Run) => (successfulTps(r) > 0 ? r.gps / successfulTps(r) : 0); + const runs = [current.mix?.run, current.payment?.run, current.swap?.run, da.mix, da.payment, da.swap].filter((r): r is Run => !!r?.storage); + const run = runs.toSorted((a, b) => gasPerTx(b) - gasPerTx(a) || b.createdAt - a.createdAt)[0]; + return run ? { storage: run.storage!, run } : null; +} + +const YEAR_SECONDS = 31_557_600; +const HEAVIEST = 'Uses the workload with the heaviest transactions, because bench workloads with lighter transactions than mainnet overstate history bytes per gas. A production measurement will replace this.'; +const days = (seconds: number) => Math.round(seconds / 86_400); +const years = (n: number) => (n === 1 ? 'one year' : `${n} years`); + +// Disk ceilings: the gas rate at which the budget just holds what each node keeps. Bench bytes per gas +// is a lower bound (the sequencer prunes history), so both ceilings are upper bounds. +export function diskLimits(source: StorageSource | null, { policy, production }: Pick): Limit[] { + const runway = policy?.runwayYears; + const state = production?.stateBytesPerGas; + const archiveNow = production?.archiveBytes; + const policyText = runway + ? `runway policy: full node ${years(runway.fullNode)}, archive ${years(runway.archive)}${runway.help ? ` (${runway.help})` : ''}` + : 'no runway policy is set'; + type Row = { mgas: number; years: number; evidence: string; overBudget: string; help: string }; + const row = (id: string, label: string, need: string | null, r?: () => Row): Limit => { + if (need !== null) return { id, label, value: null, ceiling: '—', evidence: `needs ${need}`, help: `Needs ${need}; ${policyText}.` }; + const { mgas, years: runwayYears, evidence, overBudget, help } = r!(); + return mgas <= 0 + ? { id, label, value: 0, ceiling: 'over budget', evidence: overBudget, help, runwayYears } + : { id, label, value: mgas, ceiling: `≤ ${fmt(mgas)}`, evidence, help, runwayYears }; + }; + if (!source) { + const need = 'a bench storage-growth run'; + return [row('disk-full', 'Disk · full node', need), row('disk-archive', 'Disk · archive node', need)]; + } + const { storage: { diskBudgetBytes: budget, bytesPerGas, current, fullNode }, run } = source; + const from = `the ${WORKLOAD[run.workload]} run`; + const budgetText = `${bytes(budget)} disk budget (a bench config assumption)`; + + const full = row( + 'disk-full', 'Disk · full node', + !runway ? 'a runway policy' : !state ? 'production state growth' : bytesPerGas.proofs === null ? 'proof storage growth' : null, + () => { + const historyDays = days(fullNode.historyWindowSeconds); + const proofDays = days(fullNode.proofWindowSeconds); + const perMgas = 1e6 * (bytesPerGas.history * fullNode.historyWindowSeconds + bytesPerGas.proofs! * fullNode.proofWindowSeconds + state!.value * YEAR_SECONDS * runway!.fullNode); + const kept = `${historyDays} days of history, ${proofDays} days of proofs and ${years(runway!.fullNode)} of state growth`; + return { + mgas: (budget - current.state) / perMgas, + years: runway!.fullNode, + evidence: `Projected from bench growth: ${kept} fit in ${bytes(budget)}. Upper bound.`, + overBudget: `Projected from bench growth: today's state (${bytes(current.state)}) leaves no room in ${bytes(budget)}.`, + help: `Inputs: ${budgetText}; today's state ${bytes(current.state)}, measured at ${from}'s snapshot; ${bytesPerGas.history.toPrecision(3)} bytes per gas of history and ${bytesPerGas.proofs!.toPrecision(3)} of proofs from ${from}; ${state!.value} bytes per gas of production state growth${state!.source ? ` from ${state!.source}` : ''}; ${policyText}. Upper bound: history bytes per gas is a lower bound, because the sequencer prunes history. ${HEAVIEST}`, + }; + }, + ); + + // storage-growth.json's archive bytes per gas sums every MDBX table, state included, with static files + // and RocksDB. Bench runs write almost no new state, so production state growth is added on top; that + // double counts the bench run's tiny state growth. + const archive = row( + 'disk-archive', 'Disk · archive node', + !runway ? 'a runway policy' : !archiveNow ? 'the production archive size' : null, + () => { + const perGas = bytesPerGas.archive + (state?.value ?? 0); + const stateText = state + ? ` plus ${state.value} bytes per gas of mainnet state growth${state.source ? ` from ${state.source}` : ''}, since the bench run writes almost no new state` + : ' (bench state only)'; + return { + mgas: (budget - archiveNow!.value) / (1e6 * perGas * YEAR_SECONDS * runway!.archive), + years: runway!.archive, + evidence: `Projected from bench growth: ${years(runway!.archive)} ${runway!.archive === 1 ? 'fills' : 'fill'} the ${bytes(budget)} disk (${bytes(archiveNow!.value)} used today). Upper bound.`, + overBudget: `Projected from bench growth: today's archive (${bytes(archiveNow!.value)}) already fills the ${bytes(budget)} disk.`, + help: `Inputs: ${budgetText}; today's archive ${bytes(archiveNow!.value)} from ${archiveNow!.source} on ${archiveNow!.asOf}${archiveNow!.note ? ` (${archiveNow!.note})` : ''}; ${bytesPerGas.archive.toPrecision(3)} bytes per gas from ${from}${stateText}; ${policyText}. Upper bound: bench bytes per gas is a lower bound, because the sequencer prunes history. That workload may write more bytes per gas than mainnet traffic does (Base's whole archive is ${bytes(archiveNow!.value)}), which makes the ceiling conservative in the other direction. ${HEAVIEST}`, + }; + }, + ); + return [full, archive]; +} + +function LastChange({ history }: { history: ProvenCapacity['history'] }) { + const [previous, last] = history.slice(-2); + const down = last.mgas !== null && previous.mgas !== null && last.mgas < previous.mgas; + return ( + <> + {down ? '↓' : '↑'} from {previous.mgas === null ? 'none' : `${previous.mgas} Mgas/s`} on {day(last.at)} + {last.compareUrl ? <> · compare : null} + + ); +} + +const DT = 'text-bds-gray-60'; + +function ProvenBy({ proven, history }: { proven: Proven; history: ProvenCapacity['history'] }) { + const { run } = proven; + return ( +
+ Proven by +
+
Run
+
{run.outputDir}
+
Rate
+
{run.mgas} → {(run.gps / 1e6).toFixed(1)} Mgas/s
+
Commit
+
{run.commit.slice(0, 7)}
+
Date
+
{day(run.createdAt)}
+ {history.length >= 2 ? <>
Change
: null} +
Steps
+
+
    + {run.steps.map((s) => ( +
  • +
  • + ))} +
+
+
+
+ ); +} + +// A ≤ or ≥ ceiling bounds its tx/s the same way; other ceilings convert approximately. +const bound = (ceiling: string) => /^[≤≥]/.exec(ceiling)?.[0]; +const ceilingHelp = (gasPerTx: number | null) => + `tx/s for payments and swaps uses that workload's transactions; ${gasPerTx ? `every other row uses today's mainnet average of ~${Math.round(gasPerTx / 1e3)}k gas per transaction` : 'the snapshot has no transactions per second, so every other row stays in Mgas/s'}. Reverted transactions still use gas and blob space.`; + +export function Bottlenecks({ limits, max, gasLimit, proven, history, mainnetGasPerTx = null }: { limits: Limit[]; max: number; gasLimit: number | null; proven: Proven | null; history: ProvenCapacity['history']; mainnetGasPerTx?: number | null }) { + const pct = (v: number) => `${Math.min(100, (v / max) * 100)}%`; + const ranked = limits.filter((l) => l.value !== null).length; + const fixed = [{ label: 'TEE proving, RPC under sync', evidence: 'Not measured yet.' }]; + return ( +
+ What breaks first · Mgas/s + + + + + + + + + {limits.map((l, i) => { + const next = i === 0 && l.value !== null; + return ( + + + + + + + + ); + })} + {fixed.map((f) => ( + + + + ))} + +
#LimitCeiling {ceilingHelp(mainnetGasPerTx)}Evidence
{i < ranked ? i + 1 : '—'} + {l.label} + {l.help ? <> {l.help} : null} + {next ? next : null} + + {l.ceiling}{l.value !== null && l.value > 0 ? ' Mgas/s' : ''} + {l.perSecond === undefined ? null : {bound(l.ceiling) ?? '≈'} {fmt(l.perSecond)} tx/s} + {l.ceilingHelp ? {l.ceilingHelp} : null} + {/* Measured is the norm; only the exception is tagged. */} + {l.tag === 'estimated' ? : null} + +
+ {l.value === null ? null :
} + {gasLimit === null ? null : } +
+
{l.evidence}
—{f.label}—{f.evidence}
+
+ {proven ? : null} +
+ ); +} diff --git a/app/scaling/components/CeilingCards.tsx b/app/scaling/components/CeilingCards.tsx index 96bbc21..fa34cfc 100644 --- a/app/scaling/components/CeilingCards.tsx +++ b/app/scaling/components/CeilingCards.tsx @@ -2,11 +2,11 @@ import Link from 'next/link'; import { Card } from '../../components/ui/Card'; import { Text } from '../../components/ui/Text'; +import type { DaWorkload } from '../da/workloads'; import type { UnifiedRun } from '../runs/lib/unified'; import { deriveBinding, type Ceiling, type CeilingInput } from '../snapshot.mjs'; import { DaWorkloadRows } from './DaWorkloads'; import { Info } from './Info'; -import { RunBackedCeilingCard } from './RunBackedCeilingCard'; import { Tag } from './Tag'; // Networking stays a work area but has no ceiling page: nothing measures its limit. @@ -17,18 +17,7 @@ function input({ name, value, unit, status, asOf }: CeilingInput): string { return [name, shown, value === null ? null : status, asOf && new Date(asOf).toISOString().slice(0, 10)].filter(Boolean).join(' · '); } -export function CeilingCards({ ceilings }: { ceilings: Ceiling[] }) { - return ( -
- Ceilings by bottleneck · Mgas/s -
- {AREAS.map(([area]) => )} -
-
- ); -} - -export function CeilingCard({ area, ceilings, run }: { area: Ceiling['area']; ceilings: Ceiling[]; run?: UnifiedRun }) { +export function CeilingCard({ area, ceilings, workloads, run }: { area: Ceiling['area']; ceilings: Ceiling[]; workloads?: DaWorkload[]; run?: UnifiedRun }) { const name = AREAS.find(([a]) => a === area)![1]; const c = ceilings.find((x) => x.area === area)!; return ( @@ -45,7 +34,7 @@ export function CeilingCard({ area, ceilings, run }: { area: Ceiling['area']; ce {area === 'da' ? ( <>

{c.reason}

- + ) : ( <> diff --git a/app/scaling/components/DaWorkloads.tsx b/app/scaling/components/DaWorkloads.tsx index f14cd36..54cab58 100644 --- a/app/scaling/components/DaWorkloads.tsx +++ b/app/scaling/components/DaWorkloads.tsx @@ -1,20 +1,23 @@ +'use client'; + import { cn } from '../../components/ui/cn'; import { Card } from '../../components/ui/Card'; import { Text } from '../../components/ui/Text'; -import { BLOB_MAX, BLOB_TARGET, bindingWorkload, daCapacity, DA_WORKLOADS, fmt } from '../da/workloads'; +import { BLOB_MAX, BLOB_TARGET, bindingWorkload, daCapacity, daWorkloads, DA_WORKLOADS, fmt, type DaWorkload } from '../da/workloads'; +import { useProvenCapacity } from './ProvenCapacity'; import { Tag } from './Tag'; const TH = 'px-4 py-2 text-right text-[12px] font-[500] text-bds-gray-60 first:text-left'; const TD = 'whitespace-nowrap px-4 py-3 text-right font-mono first:text-left first:font-sans'; // The DA ceiling card's body: one aligned row per workload instead of the generic input list. -export function DaWorkloadRows() { - const binding = bindingWorkload(); +export function DaWorkloadRows({ workloads = DA_WORKLOADS }: { workloads?: DaWorkload[] }) { + const binding = bindingWorkload(workloads); const row = 'grid grid-cols-[1fr_2.75rem_3rem] items-baseline gap-x-2 py-1 text-right'; return (
  • Mgas/stx/s
  • - {DA_WORKLOADS.map((w) => { + {workloads.map((w) => { const { mgasPerSecond, txPerSecond } = daCapacity(w, BLOB_TARGET); return (
  • @@ -28,9 +31,22 @@ export function DaWorkloadRows() { ); } -export function DaWorkloads({ observedP50 }: { observedP50: number | null }) { - const binding = bindingWorkload(); - const rows = DA_WORKLOADS.map((w) => ({ w, target: daCapacity(w, BLOB_TARGET), max: daCapacity(w, BLOB_MAX) })); +function BlobsPerSlot({ bench }: { bench: NonNullable }) { + const { da, run } = bench; + const verdict = da.withinTarget === null ? null : da.withinTarget ? 'within' : 'over'; + return ( + + {da.result.blobs_per_l1_slot.toFixed(1)} / {da.target ?? BLOB_TARGET} + {verdict ? {verdict} : null} + + ); +} + +export function DaWorkloads({ observedP50, benchUrl }: { observedP50: number | null; benchUrl?: string }) { + const bench = useProvenCapacity(benchUrl); + const workloads = daWorkloads(bench.error ? undefined : bench.data?.da); + const binding = bindingWorkload(workloads); + const rows = workloads.map((w) => ({ w, target: daCapacity(w, BLOB_TARGET), max: daCapacity(w, BLOB_MAX) })); const scale = 1.1 * Math.max(...rows.map((r) => r.max.mgasPerSecond), observedP50 ?? 0); const pct = (v: number) => `${(v / scale) * 100}%`; return ( @@ -65,7 +81,7 @@ export function DaWorkloads({ observedP50 }: { observedP50: number | null }) { - {['Workload', 'Bytes / tx', 'Gas / tx', 'Bytes / gas', 'tx/s', 'Mgas/s', 'Source'].map((h) => )} + {['Workload', 'Bytes / tx', 'Gas / tx', 'Bytes / gas', 'tx/s', 'Mgas/s', 'Bench blobs / slot', 'Source'].map((h) => )} @@ -79,6 +95,7 @@ export function DaWorkloads({ observedP50 }: { observedP50: number | null }) { + +
    {h}{h}
    {(w.bytesPerTx / w.gasPerTx).toFixed(5)} {fmt(target.txPerSecond)} {fmt(target.mgasPerSecond)}{w.bench ? : '—'} {' '} {w.source ? ( diff --git a/app/scaling/components/Guardrails.tsx b/app/scaling/components/Guardrails.tsx index e0980f3..6001bf0 100644 --- a/app/scaling/components/Guardrails.tsx +++ b/app/scaling/components/Guardrails.tsx @@ -1,62 +1,143 @@ +'use client'; + import Link from 'next/link'; import { cn } from '../../components/ui/cn'; import { Card } from '../../components/ui/Card'; import { Text } from '../../components/ui/Text'; +import type { Run } from '../proven/capacity'; import type { Guardrail } from '../snapshot.mjs'; -import { Info } from './Info'; +import { useProvenCapacity, usageAsOf } from './ProvenCapacity'; import { Tag } from './Tag'; const TH = 'px-4 py-2 text-left text-[12px] font-[500] text-bds-gray-60'; const TD = 'px-4 py-3 align-top'; const DOT: Record = { ok: 'bg-bds-green-50', breached: 'bg-bds-red-50', unknown: 'bg-bds-gray-30', 'not-monitored': 'bg-bds-gray-30' }; const STATUS: Record = { ok: 'OK', breached: 'Breached', unknown: 'Unknown', 'not-monitored': 'Not monitored' }; -const rank = (g: Guardrail) => (g.status === 'not-monitored' ? 2 : g.value === null ? 1 : 0); const fmt = (n: number, unit: string) => `${n.toLocaleString('en-US', { maximumFractionDigits: 2 })} ${unit}`.trim(); -export function Guardrails({ guardrails, detailed = false }: { guardrails: Guardrail[]; detailed?: boolean }) { +export const KEEP_UP_BLOCKS = 3; +export const REPLAY_BLOCKS_PER_SECOND = 5; +// Proof-node budgets at the tested rate, owner-approved 2026-10-27. +export const PROOF_LAG_P99_BLOCKS = 10; +export const EXECUTE_PAYLOAD_P99_MS = 1000; + +const judge = (g: Omit): Guardrail => ({ + ...g, + status: g.value === null || g.threshold === null ? 'unknown' : (g.comparator === '<=' ? g.value <= g.threshold : g.value >= g.threshold) ? 'ok' : 'breached', +}); + +// Production validators may trail the tip by at most 3 blocks. `run` is the qualifying mainnet-mix +// bench run: undefined without a bench host, null when none is loaded. +export function scorecardGuardrails(snapshot: Guardrail[], run: Run | null | undefined): Guardrail[] { + const keepUp = (g?: Guardrail) => judge({ + id: 'validator-keep-up', label: 'Validator keep-up', value: g?.value ?? null, unit: 'blocks', threshold: KEEP_UP_BLOCKS, comparator: '<=', + thresholdTag: 'set', owner: g?.owner ?? null, asOf: g?.asOf ?? null, + help: `How far production validators trail the tip, from the snapshot. They must stay within ${KEEP_UP_BLOCKS} blocks.`, + }); + const rows = snapshot.some((g) => g.id === 'validator-keep-up') + ? snapshot.map((g) => (g.id === 'validator-keep-up' ? keepUp(g) : g)) + : [...snapshot, keepUp()]; + if (run === undefined) return rows; + const source = run ? ` From bench run ${run.outputDir}.` : ' No qualifying mainnet-mix bench run is loaded.'; + const bench = { comparator: '<=' as const, threshold: null, thresholdTag: 'unset' as const, owner: null, asOf: run ? new Date(run.createdAt).toISOString() : null }; + return [ + ...rows, + judge({ + ...bench, id: 'proof-node-head-lag', label: 'Proof-node head lag p99', value: run?.proofNode?.proofs?.headLagBlocks.p99 ?? null, unit: 'blocks', + threshold: PROOF_LAG_P99_BLOCKS, thresholdTag: 'set', + help: `How far proof history trails the inserted head while the proof node replays the bench blocks.${source}`, + }), + judge({ + ...bench, id: 'proof-node-execute-payload', label: 'debug_executePayload p99', value: run?.proofNode?.proofs?.methods.debug_executePayload?.latency.p99Ms ?? null, unit: 'ms', + threshold: EXECUTE_PAYLOAD_P99_MS, thresholdTag: 'set', + help: `Latency of the witness call the prover makes for each block, on the proof node.${source}`, + }), + judge({ + ...bench, id: 'validator-replay-headroom', label: 'Validator replay headroom', value: run?.replay?.blocksPerSecond ?? null, unit: 'blocks/s', + comparator: '>=', threshold: REPLAY_BLOCKS_PER_SECOND, thresholdTag: 'set', + help: `Blocks per second a validator replays the bench blocks at. Keeping up needs ${REPLAY_BLOCKS_PER_SECOND}, one block per ${1000 / REPLAY_BLOCKS_PER_SECOND} ms.${source}`, + }), + ]; +} + +function Table({ rows }: { rows: Guardrail[] }) { return ( -
    - {detailed ? null : Guardrails →} - - - - - - - - + +
    GuardrailValueThresholdStatus
    + + + + + + + + + + {rows.map((g) => ( + + + + + - - - {guardrails.toSorted((a, b) => rank(a) - rank(b)).map((g) => ( - - - - - - - ))} - -
    GuardrailValueThresholdStatus
    + {g.label}

    {g.help}

    +
    {g.value === null ? '—' : fmt(g.value, g.unit)} + + {g.threshold === null ? '—' : `${g.comparator === '<=' ? '≤' : '≥'} ${fmt(g.threshold, g.unit)}`} + {g.thresholdTag === 'set' ? null : } + {g.owner ? {g.owner} : null} + + + + +
    - {detailed ? <>{g.label}

    {g.help}

    : ( - {g.label} {g.help} - )} -
    {g.value === null ? '—' : fmt(g.value, g.unit)} - - {g.threshold === null ? '—' : `${g.comparator === '<=' ? '≤' : '≥'} ${fmt(g.threshold, g.unit)}`} - {g.thresholdTag === 'set' ? null : } - {g.owner ? {g.owner} : null} - - - - -
    -
    - Breached SLOs are not attributed to saturation until triaged. + ))} +
    + + ); +} + +// The scorecard shows counts; the guardrails page shows breached and met rows, with the rest collapsed. +export function Guardrails({ guardrails, collectedAt, detailed = false, benchUrl }: { guardrails: Guardrail[]; collectedAt: string; detailed?: boolean; benchUrl?: string }) { + const bench = useProvenCapacity(benchUrl); + const run = benchUrl ? (bench.error ? null : bench.data?.current.mix?.run ?? null) : undefined; + const rows = scorecardGuardrails(guardrails, run); + const breached = rows.filter((g) => g.status === 'breached'); + const met = rows.filter((g) => g.status === 'ok'); + const unmeasured = rows.filter((g) => g.status === 'unknown' || g.status === 'not-monitored'); + const summary = Not measured {unmeasured.length}; + if (!detailed) { + return ( +
    + Guardrails +
    + + Breached {breached.length} + {breached.map((g, i) => {i ? ', ' : ' — '}{g.label} {fmt(g.value!, g.unit)})} + + Met {met.length} +
    + {summary} +
      {unmeasured.map((g) =>
    • {g.label}
    • )}
    +
    + Guardrails → +
    +
    + ); + } + return ( +
    + +
    + {summary} +
    + + {usageAsOf(collectedAt)}; bench rows show their run. Breached SLOs are not attributed to saturation until triaged. ); } diff --git a/app/scaling/components/Headline.tsx b/app/scaling/components/Headline.tsx new file mode 100644 index 0000000..8229e66 --- /dev/null +++ b/app/scaling/components/Headline.tsx @@ -0,0 +1,207 @@ +import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; + +import { cn } from '../../components/ui/cn'; +import { Text } from '../../components/ui/Text'; +import { BLOB_TARGET, fmt } from '../da/workloads'; +import { successfulTps, type ProvenCapacity, type Workload } from '../proven/capacity'; +import type { Snapshot } from '../snapshot.mjs'; +import { times, type Limit } from './Bottlenecks'; +import { Info } from './Info'; +import type { ProvenMark } from './Ladder'; +import { day } from './ProvenCapacity'; + +export type BenchState = { configured: boolean; data?: ProvenCapacity; error?: unknown }; + +const TRAFFIC: Record = { payment: 'payments', swap: 'swaps', mix: 'tx' }; +const n = (v: number) => v.toLocaleString('en-US', { maximumFractionDigits: 1 }); + +type Props = Pick & { bench: BenchState; proven: ProvenMark; limits: Limit[] }; + +// Only states other than a loaded bench result need a note. +function benchNote({ configured, data, error }: BenchState, proven: ProvenMark, qualified: Snapshot['ladder']['qualified']): ReactNode { + if (!configured) { + if (!proven) return null; + return <>Proven capacity from the snapshot{qualified.evidence?.map((l) => · {l.label})}; + } + if (error) return 'Could not load bench results. The bench host is reachable only on VPN and must allow this origin via CORS.'; + if (!data) return 'Loading bench results…'; + // A run whose files failed to load is skipped, so an older result at its rate may count instead. + const partial = data.unknown.length ? `${data.unknown.length} run(s) could not be loaded, so this may be wrong.` : ''; + return data.current.mix ? partial || null : `No main run passed in the last 14 days. ${partial}`.trim(); +} + +// "≥ 21×" when proven is the top rate tested, a floor. +export const headroomLabel = (proven: NonNullable, today: number) => `${proven.atLeast ? '≥ ' : ''}${times(proven.value / today)}`; + +// Today's mainnet gas per transaction: median gas rate ÷ transactions per second. Null without either. +export const mainnetGasPerTx = ({ ladder, usage }: Pick) => { + const p50 = ladder.observed.p50.value; + const tps = usage?.transactionsPerSecond.value ?? null; + return p50 !== null && tps ? (p50 * 1e6) / tps : null; +}; + +export type Placement = { side: 'center' | 'left' | 'right'; x: number }; + +// Centers a label on x, or pins it to the nearer edge when centering would overflow. `estimate` is an +// approximate label width; a centered label renders with translateX(-50%), so it stays exact. +export function place(x: number, estimate: number, width: number): Placement { + if (x - estimate / 2 < 0) return { side: 'left', x: 0 }; + if (x + estimate / 2 > width) return { side: 'right', x: width }; + return { side: 'center', x }; +} + +const placed = (p: Placement): CSSProperties => + p.side === 'left' ? { left: 0 } : p.side === 'right' ? { right: 0 } : { left: p.x, transform: 'translateX(-50%)' }; + +function Capacity({ ladder, usage, proven }: Pick) { + const ref = useRef(null); + const [width, setWidth] = useState(240); + useLayoutEffect(() => { + if (!ref.current) return; + const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)); + observer.observe(ref.current); + return () => observer.disconnect(); + }, []); + const today = ladder.observed.p50.value; + const tps = usage?.transactionsPerSecond; + const span = proven && today !== null && today > 0 ? proven : null; + const todayX = span ? Math.min(width, (today! / span.value) * width) : 0; + const headroom = span ? headroomLabel(span, today!) : ''; + const todayText = `today ${today === null ? '' : n(today)}${tps ? ` · ${fmt(tps.value)} tx/s` : ''}`; + return ( +
    + + Proven capacity + Execution, validators and proof node all pass at this rate on the mainnet snapshot. Workload tiles count successful transactions per second. + + + {proven ? `${proven.atLeast ? '≥ ' : ''}${n(proven.value)}` : '—'} + Mgas/s + + {span ? ( +
    + + {headroom} + Proven capacity ÷ today's median gas rate. What breaks first lists the limits that apply before proven capacity. + +
    +
    + + + + today {n(today!)} + {tps ? · {fmt(tps.value)} tx/s : null} + {tps ? {tps.help} : null} + + + proven {span.atLeast ? '≥ ' : ''}{n(span.value)} + +
    + ) : null} +
    + ); +} + +type Cap = 'DA' | 'gas limit' | 'execution'; +type Translation = { + label: ReactNode; + gasPerTx: number | null; + proven: { mgas: number; atLeast: boolean } | null; + da: number; + reverted?: number; +}; + +// The lowest of the three ceilings; ties go to DA, then the gas limit. +export function sustainable(proven: number, da: number, gasLimit: number | null): { mgas: number; cap: Cap } { + const caps: [Cap, number][] = [['DA', da], ['gas limit', gasLimit ?? Infinity], ['execution', proven]]; + const [cap, mgas] = caps.reduce((low, c) => (c[1] < low[1] ? c : low)); + return { mgas, cap }; +} + +// The headline column and the tiles share these slots so their label and value rows line up. +const SLOTS = 'flex flex-col gap-1.5 border py-3'; +const LABEL = 'flex h-4 items-center gap-1 overflow-hidden whitespace-nowrap text-[11px] font-[600] uppercase leading-4 tracking-wide text-bds-gray-60'; +const VALUE = 'whitespace-nowrap leading-[44px] tabular-nums'; +const CAP_TONE: Record = { DA: 'text-bds-red-60', 'gas limit': 'text-bds-orange-60', execution: '' }; +const compact = (v: number) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(v).toLowerCase(); + +function Tile({ row: r, gasLimit }: { row: Translation; gasLimit: number | null }) { + const s = r.proven && sustainable(r.proven.mgas, r.da, gasLimit); + const tx = (mgas: number) => (mgas * 1e6) / r.gasPerTx!; + return ( +
    + {r.label} + + {!r.proven ? 'pending' : r.gasPerTx === null ? '—' : <>{r.proven.atLeast ? '≥ ' : ''}{compact(tx(r.proven.mgas))}/s} + + + sustainable today {!s ? 'pending' : r.gasPerTx === null ? '—' : `${fmt(tx(s.mgas))}/s`} + + Highest rate we can run continuously now: below the L1 blob target and within the current gas limit{gasLimit === null ? '' : ` (${n(gasLimit)} Mgas/s)`}. Capped by DA or the gas limit, not execution. + + + {s ? `capped by ${s.cap}` : ''} + {/* Reserved on every tile so a note on one does not shift the others. */} + + {r.reverted !== undefined && r.reverted > 0.01 ? ( + <> + {Math.round(r.reverted * 100)}% reverted + Reverted transactions still use gas and count toward Mgas/s; tx/s counts successful ones only. + + ) : null} + +
    + ); +} + +const MIX_DA = 'Its DA cap comes from the bench mix run, which is lighter per transaction and compresses worse than mainnet, so the cap is conservative.'; + +// Bench workloads convert with their proven run's gas per successful transaction; today's traffic mix +// with mainnet's own gas per transaction, since the bench mix only approximates mainnet. +function translations({ ladder, usage, bench, proven, limits }: Pick): Translation[] { + const daOf = (w: Workload) => limits.find((l) => l.id === `da-${w}`)!.value!; + const benchRow = (w: Workload, label: string): Translation => { + const p = bench.data?.current[w] ?? null; + return { + label, + gasPerTx: p ? p.run.gps / successfulTps(p.run) : null, + proven: p && { mgas: p.mgas, atLeast: p.atTop }, + da: daOf(w), + reverted: p?.run.reverted, + }; + }; + const p50 = ladder.observed.p50.value; + const gasPerTx = mainnetGasPerTx({ ladder, usage }); + const tps = usage?.transactionsPerSecond.value ?? null; + const mixHelp = gasPerTx !== null + ? `At today's mainnet average of ~${Math.round(gasPerTx / 1e3)}k gas per transaction (${n(p50!)} Mgas/s ÷ ${fmt(tps!)} tx/s), not the bench mix. ${MIX_DA}` + : `The snapshot has no transactions per second, so this row stays in gas: today ${p50 === null ? 'not measured' : `${n(p50)} Mgas/s`}. ${MIX_DA}`; + return [ + benchRow('payment', 'Payments (B20)'), + benchRow('swap', 'Swaps (Uniswap v3)'), + { + label: Today's traffic mix {mixHelp}, + gasPerTx, + proven: proven && { mgas: proven.value, atLeast: proven.atLeast }, + da: daOf('mix'), + }, + ]; +} + +export function Headline({ ladder, usage, bench, proven, limits }: Props) { + const note = benchNote(bench, proven, ladder.qualified); + const rows = translations({ ladder, usage, bench, proven, limits }); + return ( +
    +
    +
    + {rows.map((row, i) => )} +
    + {note ? {note} : null} +
    + ); +} diff --git a/app/scaling/components/Ladder.tsx b/app/scaling/components/Ladder.tsx index c69eeed..548f017 100644 --- a/app/scaling/components/Ladder.tsx +++ b/app/scaling/components/Ladder.tsx @@ -4,121 +4,184 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { cn } from '../../components/ui/cn'; import { Text } from '../../components/ui/Text'; -import type { Proven } from '../proven/capacity'; -import { deriveBinding, type Rung, type Snapshot } from '../snapshot.mjs'; +import type { Rung, Snapshot } from '../snapshot.mjs'; +import type { Limit } from './Bottlenecks'; import { Info } from './Info'; -import { day, ProvenCapacity, useProvenCapacity } from './ProvenCapacity'; -import { Tag } from './Tag'; -import { layoutRungs, type Anchor } from './ladderLayout'; -const LABEL_PX = 150; -const ROW = 44; -const AXIS = 12; -const AREA_NAME = { da: 'DA', execution: 'execution', state: 'state', networking: 'networking' }; -const ALIGN: Record = { start: 'items-start text-left', middle: 'items-center text-center', end: 'items-end text-right' }; -const SHIFT: Record = { start: 'none', middle: 'translateX(-50%)', end: 'translateX(-100%)' }; +const BAR_H = 24; +// Each label is two 12px lines; rows sit GAP_PX from the bar and touch each other, so a second row stays +// within 30px of the bar. +const ROW_PX = 24; +const GAP_PX = 6; +const n = (v: number) => v.toLocaleString('en-US', { maximumFractionDigits: 1 }); -type Props = Pick & { benchUrl?: string; dashboardUrl?: string }; -type Item = { id: string; label: string; value: number | null; tag: string; help: string; limit?: boolean; atLeast?: boolean; source?: string }; +export type ProvenMark = { value: number; atLeast: boolean; help: string } | null; -function benchItem({ mgas, atTop, run }: Proven) { - const top = atTop ? ` ≥ because ${mgas} Mgas/s is the highest rate the bench runs.` : ''; - const help = `Bench-proven: the highest rate whose latest main run in the last 14 days was comparable and delivered ≥ 95% of target. Run ${run.outputDir}, commit ${run.commit.slice(0, 7)}, ${day(run.createdAt)}.${top} DA and proof guardrails are not checked.`; - return { value: mgas, tag: 'measured' as const, help, atLeast: atTop, source: 'bench' }; -} +type Tone = 'today' | 'p99' | 'fee' | 'da' | 'disk' | 'gas' | 'proven'; +export type Point = { id: string; value: number; valueText: string; name: string; tone: Tone; help: string }; +export type PlacedPoint = Point & { x: number; offScale: boolean; side: 'above' | 'below'; tier: number; left: number; width: number; compact: boolean }; -function items({ ladder, ceilings, windows }: Props, proven: Proven | null | undefined): Item[] { - const rung = (id: string, label: string, r: Rung): Item => - ({ id, label, value: r.value, tag: r.tag, help: r.window ? `${r.help} Window: ${windows[r.window].label}.` : r.help }); - const bench = proven ? benchItem(proven) : null; - const binding = deriveBinding(bench ? ceilings.map((c) => (c.area === 'execution' ? { ...c, value: bench.value, tag: bench.tag } : c)) : ceilings); - const ceiling: Item = binding - ? { id: 'ceiling', label: `Ceiling · ${AREA_NAME[binding.area]}`, value: binding.value, tag: binding.tag, help: binding.help, ...(bench && binding.area === 'execution' ? bench : {}) } - : { id: 'ceiling', label: 'Ceiling', value: null, tag: 'not-modeled', help: `No area ceiling is measured or modeled yet: ${ceilings.map((c) => `${AREA_NAME[c.area]} ${c.tag}`).join(', ')}.` }; - return [ - rung('p50', 'Observed p50', ladder.observed.p50), - rung('p99', 'Observed p99', ladder.observed.p99), - rung('operating', 'Operating point', ladder.operatingPoint), - bench ? { id: 'qualified', label: 'Qualified sustained', ...bench } : rung('qualified', 'Qualified sustained', ladder.qualified), - ceiling, - { ...rung('limit', 'Configured limit', ladder.configuredLimit), limit: true }, - ]; -} +const TEXT: Record = { + today: 'text-bds-blue-90 dark:text-white', + p99: 'text-bds-blue-50', + fee: 'text-bds-gray-60', + da: 'text-bds-red-60', + disk: 'text-bds-purple-60', + gas: 'text-bds-orange-60', + proven: 'text-base-blue', +}; +const TICK: Record = { + today: 'border-bds-blue-90 dark:border-white', + p99: 'border-bds-blue-50', + fee: 'border-dotted border-bds-gray-60', + da: 'border-bds-red-60', + disk: 'border-bds-purple-60', + gas: 'border-dashed border-bds-orange-60', + proven: 'border-base-blue', +}; +const SHADES = { + median: 'fill-base-blue bg-base-blue', + p99: 'fill-bds-blue-30 bg-bds-blue-30', + allowed: 'fill-bds-blue-10 bg-bds-blue-10 dark:fill-bds-blue-80 dark:bg-bds-blue-80', + proven: 'fill-bds-gray-10 bg-bds-gray-10 dark:fill-white/10 dark:bg-white/10', +}; +const SHORT: Record = { 'da-payment': 'DA · payments', 'da-swap': 'DA · swaps', 'da-mix': 'DA · mix (bench)', 'disk-archive': 'archive disk', 'disk-full': 'full-node disk' }; -function translation({ gasPerPayment, gasPerSwap }: Snapshot['translations']): string { - if (!gasPerPayment && !gasPerSwap) return 'Translation to payments/s and swaps/s: constants pending'; - const per = (c: typeof gasPerPayment, noun: string) => (c ? `${Math.round(1e6 / c.value)} ${noun}/s` : `${noun}/s pending`); - return `1 Mgas/s ≈ ${per(gasPerPayment, 'payments')} ≈ ${per(gasPerSwap, 'swaps')}`; +// Approximate label widths: a bold 12px value with its help icon over an 11px name. +const widthOf = (p: Point, offScale: boolean, compact: boolean) => + Math.max((p.valueText.length + (offScale ? 2 : 0)) * 7.6 + 24, compact ? 0 : p.name.length * 6) + 2; + +// Labels alternate above and below in value order, in one row hugging the bar on each side. A label +// that collides first tries the other side, then a nudge that keeps it over its tick, then drops its +// name (listed under the ladder); only then does it take a second row. Off-scale points sit at the +// right edge without a tick. +export function layoutPoints(points: Point[], max: number, width: number): PlacedPoint[] { + const ends: Record<'above' | 'below', number[]> = { above: [], below: [] }; + return points.toSorted((a, b) => a.value - b.value).map((p, i) => { + const offScale = p.value > max; + const x = offScale ? width : (Math.max(0, p.value) / max) * width; + const preferred = i % 2 === 0 ? 'above' : 'below'; + const sides = [preferred, preferred === 'above' ? 'below' : 'above'] as const; + for (let tier = 0; ; tier++) { + for (const compact of [false, true]) { + const w = Math.min(width, widthOf(p, offScale, compact)); + const centered = Math.min(Math.max(x - w / 2, 0), width - w); + for (const nudge of [false, true]) { + for (const side of sides) { + const end = ends[side][tier] ?? -Infinity; + const left = nudge ? Math.max(centered, end + GAP_PX) : centered; + const clear = left >= end + GAP_PX && left + w <= width && Math.abs(left + w / 2 - x) <= w; + // A new, empty row always fits, so the loop ends. + if (clear || end === -Infinity) { + ends[side][tier] = left + w; + return { ...p, x, offScale, side, tier, left, width: w, compact }; + } + } + } + } + } + }); } -export function Ladder(props: Props) { - const axisRef = useRef(null); +type Props = Pick & { proven: ProvenMark; max: number; limits: Limit[] }; + +export function Ladder({ ladder, windows, proven, max, limits }: Props) { + const ref = useRef(null); const [width, setWidth] = useState(960); useLayoutEffect(() => { const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)); - observer.observe(axisRef.current!); + observer.observe(ref.current!); return () => observer.disconnect(); }, []); - const bench = useProvenCapacity(props.benchUrl); - const proven = bench.error ? null : bench.data?.current; - const all = items(props, proven); - const valued = all.flatMap((i) => (i.value === null ? [] : [{ id: i.id, value: i.value }])); - const max = props.ladder.configuredLimit.value ?? Math.max(0, ...valued.map((v) => v.value)); - const placed = new Map(max > 0 ? layoutRungs(valued, max, LABEL_PX / width).map((p) => [p.id, p]) : []); - const depth = (side: string) => 1 + Math.max(-1, ...[...placed.values()].filter((p) => p.side === side).map((p) => p.tier)); - const above = depth('above'); - const axisY = above * ROW + AXIS / 2; + const help = (r: Rung) => (r.window ? `${r.help} Window: ${windows[r.window].label}.` : r.help); + const pct = (v: number) => `${Math.min(1, v / max) * 100}%`; + const { p50, p99 } = ladder.observed; + const limit = ladder.configuredLimit.value; + const median = p50.value; + const tail = median === null ? null : p99.value; + const rung = (id: string, r: Rung, name: string, tone: Tone): Point[] => (r.value === null ? [] : [{ id, value: r.value, valueText: n(r.value), name, tone, help: help(r) }]); + const points: Point[] = [ + ...rung('today', p50, 'today', 'today'), + ...(tail === null ? [] : rung('p99', p99, 'p99', 'p99')), + ...rung('fee', ladder.operatingPoint, 'fee target', 'fee'), + ...rung('gas', ladder.configuredLimit, 'gas limit', 'gas'), + ...(proven ? [{ id: 'proven', value: proven.value, valueText: `${proven.atLeast ? '≥ ' : ''}${n(proven.value)}`, name: 'proven', tone: 'proven' as const, help: proven.help }] : []), + // The ranked breaking points from What breaks first; unranked rows have no ceiling to mark. + ...limits.filter((l) => l.value !== null && SHORT[l.id]).map((l): Point => ({ + id: l.id, + value: l.value!, + valueText: l.ceiling, + name: SHORT[l.id], + tone: l.id.startsWith('disk-') ? 'disk' : 'da', + help: l.help ?? l.evidence, + })), + ]; + const placed = layoutPoints(points, max, width); + const tiers = (side: 'above' | 'below') => placed.filter((p) => p.side === side).reduce((h, p) => Math.max(h, (p.tier + 1) * ROW_PX), 0); + const barTop = tiers('above') + GAP_PX; + const height = barTop + BAR_H + GAP_PX + tiers('below') + 16; + const nearEdge = (p: PlacedPoint) => (p.side === 'above' ? barTop - GAP_PX - p.tier * ROW_PX : barTop + BAR_H + GAP_PX + p.tier * ROW_PX); + const legend = [ + median !== null && ['up to median', SHADES.median], + tail !== null && ['median to p99', SHADES.p99], + limit !== null && ['allowed by policy', SHADES.allowed], + proven && limit !== null && proven.value > limit && ['proven, not yet allowed', SHADES.proven], + ].filter((l) => !!l); + const compact = placed.filter((p) => p.compact); return (
    - Capacity ladder · Mgas/s -
    -
    - {all.map((item) => { - const p = placed.get(item.id); - if (!p) return null; - const top = p.side === 'above' ? (above - 1 - p.tier) * ROW : above * ROW + AXIS + p.tier * ROW; - const left = `${p.x * 100}%`; + How close are we, and to what? · Mgas/s +
    + + {placed.filter((p) => !p.offScale).map((p) => ( + + ))} + {placed.map((p) => { + const top = p.side === 'above' ? nearEdge(p) - ROW_PX : nearEdge(p); return ( -
    -
    -
    - - {item.label} {item.help} - - - {item.atLeast ? '≥ ' : ''}{item.value!.toLocaleString('en-US', { maximumFractionDigits: 2 })} - {item.limit ? null : {item.tag}{item.source ? ` · ${item.source}` : ''}} - -
    -
    + + {p.tier > 0 && !p.offScale ? ( + + ) : null} + + + {p.valueText}{p.offScale ? ' →' : ''} + {p.help} + + {p.compact ? null : {p.name}} + + ); })} + 0 + {n(max)} Mgas/s
    -
      - {all.filter((item) => !placed.has(item.id)).map((item) => ( -
    • - {item.label} - - {item.help} -
    • - ))} -
    - {!proven && props.ladder.qualified.evidence?.length ? ( - - Qualified sustained by {props.ladder.qualified.evidence.map((l) => {l.label})} - + {compact.length ? ( +

    + {compact.map((p) => {p.valueText} {p.name})} +

    ) : null} - {translation(props.translations)} - {props.benchUrl ? : null} - {props.dashboardUrl ? Mainnet load, gas limit, DA ceiling → : null} +
    + {legend.map(([label, shade]) => ( + + ))} +
    ); } diff --git a/app/scaling/components/Progress.tsx b/app/scaling/components/Progress.tsx deleted file mode 100644 index fa37381..0000000 --- a/app/scaling/components/Progress.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Card } from '../../components/ui/Card'; -import { EmptyState } from '../../components/ui/EmptyState'; -import { Text } from '../../components/ui/Text'; -import type { WorkItem } from '../snapshot.mjs'; -import { Info } from './Info'; -import { RunEvidence } from './RunEvidence'; -import { Tag } from './Tag'; - -export function Progress({ work }: { work: WorkItem[] }) { - return ( -
    - Progress - {work.length === 0 ? ( - - ) : ( - - {work.map((w) => )} - - )} - Expected effects are not summed. -
    - ); -} - -function Row({ item: w }: { item: WorkItem }) { - const l = w.linear; - return ( -
    - - {w.title} - -

    Hypothesis: {w.hypothesis}

    -

    Change: {w.change}

    -

    Owner: {w.owner ?? 'none assigned'}

    -
    - - -
    - {l ? ( - - - - - {l.scopeDone}/{l.scopeTotal} - - ) : null} -

    Expected: {w.expected ?? 'Not estimated'}

    - {w.evidence && w.realized ?

    Realized: {w.realized}

    : null} - {w.evidence ? : null} -
    - {[w.evidence, l && { label: 'Linear', url: l.url }, ...w.links].map((link) => link ? ( - {link.label} - ) : null)} -
    -
    - ); -} diff --git a/app/scaling/components/ProvenCapacity.tsx b/app/scaling/components/ProvenCapacity.tsx index 1c84d5c..76744c2 100644 --- a/app/scaling/components/ProvenCapacity.tsx +++ b/app/scaling/components/ProvenCapacity.tsx @@ -2,54 +2,11 @@ import useSWR from 'swr'; -import { Text } from '../../components/ui/Text'; -import { loadProvenCapacity, type ProvenCapacity as Capacity } from '../proven/capacity'; -import { LineChart } from '../runs/components/LineChart'; - -export type Bench = Capacity & { now: number }; +import { loadProvenCapacity } from '../proven/capacity'; export const day = (t: number) => new Date(t).toISOString().slice(0, 10); -const mgas = (m: number | null) => (m === null ? 'none' : `${m} Mgas/s`); +export const usageAsOf = (collectedAt: string) => `Usage data as of ${day(Date.parse(collectedAt))}`; export function useProvenCapacity(benchUrl?: string) { - return useSWR(benchUrl ? ['scaling-proven-capacity', benchUrl] : null, async (): Promise => { - const now = Date.now(); - return { ...(await loadProvenCapacity(benchUrl!, fetch, now)), now }; - }, { revalidateOnFocus: false }); -} - -export function ProvenCapacity({ data, error }: { data?: Bench; error?: unknown }) { - return error ? Could not load bench results. The bench host is reachable only on VPN and must allow this origin via CORS. - : !data ? Loading bench results… - : ; -} - -function Result({ capacity: { current, history, unknown, now } }: { capacity: Bench }) { - // A run whose files failed to load is skipped, so an older result at its rate may count instead. - const partial = unknown.length ? {unknown.length} run(s) could not be loaded, so this result may be wrong. : null; - if (!current) return partial ?? No main run passed in the last 14 days.; - const { run } = current; - const y = (m: number | null) => (m ?? 0) * 1e6; - const points = history.flatMap((s, i): [number, number][] => (i ? [[s.at, y(history[i - 1].mgas)], [s.at, y(s.mgas)]] : [[s.at, y(s.mgas)]])); - points.push([now, y(current.mgas)]); - return ( - <> - {partial} - - Qualified by {run.outputDir}: {run.mgas} Mgas/s run delivered {(run.gps / 1e6).toFixed(1)} Mgas/s · commit{' '} - {run.commit.slice(0, 7)} · {day(run.createdAt)} - - -
      - {history.map((s, i) => ( -
    • - {day(s.at)} - {i ? `${mgas(history[i - 1].mgas)} → ` : ''}{mgas(s.mgas)} - {s.compareUrl ? compare : null} -
    • - ))} -
    - - ); + return useSWR(benchUrl ? ['scaling-proven-capacity', benchUrl] : null, () => loadProvenCapacity(benchUrl!), { revalidateOnFocus: false }); } diff --git a/app/scaling/components/RunBackedCeilingCard.tsx b/app/scaling/components/RunBackedCeilingCard.tsx index 0881047..d3d35cf 100644 --- a/app/scaling/components/RunBackedCeilingCard.tsx +++ b/app/scaling/components/RunBackedCeilingCard.tsx @@ -2,11 +2,13 @@ import useSWR from 'swr'; +import { daWorkloads, withDaWorkloads } from '../da/workloads'; import { getDataService } from '../runs/lib/dataService'; import { getTestRunsWithStatus, type BenchmarkRuns, type BenchmarkRunWithStatus } from '../runs/lib/types'; import { toUnifiedRun } from '../runs/lib/unified'; import type { Ceiling } from '../snapshot.mjs'; import { CeilingCard } from './CeilingCards'; +import { useProvenCapacity } from './ProvenCapacity'; const gps = (r: BenchmarkRunWithStatus) => r.result?.sequencerMetrics?.gasPerSecond ?? 0; @@ -30,12 +32,16 @@ const fromRun = (c: Ceiling, run: BenchmarkRunWithStatus): Ceiling => ({ reason: 'state size not recorded', }); -export function RunBackedCeilingCard({ area, ceilings }: { area: Ceiling['area']; ceilings: Ceiling[] }) { +export function RunBackedCeilingCard({ area, ceilings: snapshotCeilings, benchUrl }: { area: Ceiling['area']; ceilings: Ceiling[]; benchUrl?: string }) { const { data } = useSWR('scaling-bench-metadata', () => getDataService().getMetadata()); + const bench = useProvenCapacity(benchUrl); + const loaded = bench.error ? undefined : bench.data; + const workloads = daWorkloads(loaded?.da); + const ceilings = loaded ? withDaWorkloads(snapshotCeilings, workloads) : snapshotCeilings; const run = data && newestPassing(data); const execution = ceilings.find((c) => c.area === 'execution')!; // A measured or modeled snapshot value outranks a run-backed estimate; the snapshot is also the fallback without the API. - if (!run || execution.tag === 'measured' || execution.tag === 'modeled') return ; + if (!run || execution.tag === 'measured' || execution.tag === 'modeled') return ; const live = ceilings.map((c) => (c === execution ? fromRun(c, run) : c)); - return ; + return ; } diff --git a/app/scaling/components/RunEvidence.tsx b/app/scaling/components/RunEvidence.tsx deleted file mode 100644 index 95f7726..0000000 --- a/app/scaling/components/RunEvidence.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import useSWR from 'swr'; - -import { Status } from '../runs/components/parts'; -import { getDataService } from '../runs/lib/dataService'; -import { getTestRunsWithStatus } from '../runs/lib/types'; -import { toUnifiedRun } from '../runs/lib/unified'; -import { RUN_PAGE } from '../snapshot.mjs'; - -export function RunEvidence({ url }: { url: string }) { - const [, id, network, timestamp] = url.match(RUN_PAGE) ?? []; - const { data: metadata } = useSWR(id ? 'scaling-bench-metadata' : null, () => getDataService().getMetadata()); - const { data: result } = useSWR(network ? ['scaling-load-result', network, timestamp] : null, () => getDataService().getLoadTestResult(network, timestamp)); - const runs = id - ? getTestRunsWithStatus({ runs: metadata?.runs.filter((r) => `${r.testConfig.BenchmarkRun}` === id) ?? [] }).map(toUnifiedRun) - : result ? [toUnifiedRun({ network, timestamp, result })] : []; - return ( - - {runs.map((r, i) => ( - - {r.gasPerSecond === null ? '—' : (r.gasPerSecond / 1e6).toFixed(1)} Mgas/s - - - ))} - - ); -} diff --git a/app/scaling/components/Scorecard.tsx b/app/scaling/components/Scorecard.tsx new file mode 100644 index 0000000..eb01a8f --- /dev/null +++ b/app/scaling/components/Scorecard.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { daWorkloads, DA_WORKLOADS, type DaWorkload } from '../da/workloads'; +import type { Proven } from '../proven/capacity'; +import type { Snapshot } from '../snapshot.mjs'; +import { bottleneckBars, Bottlenecks, diskLimits, pickStorage, rankLimits } from './Bottlenecks'; +import { Headline, mainnetGasPerTx } from './Headline'; +import { Ladder, type ProvenMark } from './Ladder'; +import { day, useProvenCapacity } from './ProvenCapacity'; +import { Work } from './Work'; +import { subsystemCards, WorkloadSubsystems } from './WorkloadSubsystems'; + +function benchMark({ mgas, atTop, run }: Proven): ProvenMark { + const top = atTop ? ` ≥ because ${mgas} Mgas/s is the highest rate the bench runs.` : ''; + const help = `Bench-proven: the highest rate whose latest main run in the last 14 days was comparable and delivered ≥ 95% of target. Run ${run.outputDir}, commit ${run.commit.slice(0, 7)}, ${day(run.createdAt)}.${top} Comparable requires every enabled pipeline step to pass; the DA blob target and proof-node lag and latency are recorded, not gated.`; + return { value: mgas, atLeast: atTop, help }; +} + +// /scaling ranks the mix by the bench mix run's DA, like payments and swaps; until one exists, mainnet's measurement +// stands in as an estimate. /scaling/da keeps the mainnet measurement. +const SCALING_WORKLOADS: DaWorkload[] = DA_WORKLOADS.map((w) => (w.payload === 'mix' ? { ...w, label: 'Mix (bench)', tag: 'estimated', asOf: null, source: undefined } : w)); + +const at = (t: number) => `${new Date(t).toISOString().slice(0, 16).replace('T', ' ')} UTC`; + +function Freshness({ snapshot, latestAt }: { snapshot: Snapshot; latestAt: number | null }) { + const usageWindow = snapshot.usage?.transactionsPerSecond.window ?? snapshot.ladder.observed.p50.window; + const parts = [ + `Usage to ${day(Date.parse(usageWindow ? snapshot.windows[usageWindow].end : snapshot.collectedAt))}`, + latestAt !== null && `bench ${at(latestAt)}`, + ].filter(Boolean); + return

    {parts.join(' · ')}

    ; +} + +// Every section shares one bench fetch and the Mgas/s scale. Without a bench host, the proven rate +// falls back to the snapshot's qualified rung. +export function Scorecard({ snapshot, benchUrl }: { snapshot: Snapshot; benchUrl?: string }) { + const bench = useProvenCapacity(benchUrl); + const data = bench.error ? undefined : bench.data; + const { ladder } = snapshot; + const proven = data?.current.mix ?? null; + const qualified = ladder.qualified; + const mark = proven ? benchMark(proven) : !benchUrl && qualified.value !== null ? { value: qualified.value, atLeast: false, help: qualified.help } : null; + const workloads = daWorkloads(data?.da, SCALING_WORKLOADS); + const limits = rankLimits(bottleneckBars(workloads, proven), workloads, proven, diskLimits(pickStorage(data), snapshot), mainnetGasPerTx(snapshot)); + const values = [ladder.observed.p50, ladder.observed.p99, ladder.operatingPoint, ladder.configuredLimit].map((r) => r.value ?? 0); + const max = Math.max(mark?.value ?? 0, ladder.configuredLimit.value ?? 0) || Math.max(0, ...values); + return ( + <> + + + + + + + + ); +} diff --git a/app/scaling/components/Work.tsx b/app/scaling/components/Work.tsx new file mode 100644 index 0000000..5ce2a37 --- /dev/null +++ b/app/scaling/components/Work.tsx @@ -0,0 +1,75 @@ +import { Card } from '../../components/ui/Card'; +import { cn } from '../../components/ui/cn'; +import { EmptyState } from '../../components/ui/EmptyState'; +import { Text } from '../../components/ui/Text'; +import { WORK_STATUSES } from '../snapshot.mjs'; +import type { Area, Link, WorkCategory, WorkItem } from '../snapshot.mjs'; +import { Info } from './Info'; + +// Status labels follow Linear's project states. +const STATUS: Record = { 'in-progress': 'In Progress', planned: 'Planned', done: 'Completed' }; +// Older entries name an area instead of a category. +const CATEGORY: Record = { da: 'DA', execution: 'Execution', state: 'Disk', networking: 'Latency' }; +const TH = 'px-3 py-2 text-left text-[11px] font-[600] uppercase tracking-wide text-bds-gray-60'; +const TD = 'px-3 py-2.5 align-top'; + +// Links in the entry's order, then its Linear project and evidence when an older entry carries them. +const linksOf = (w: WorkItem): Link[] => { + const links = [...(w.links ?? [])]; + if (w.linear && !links.some((l) => l.url === w.linear!.url)) links.push({ label: 'Linear', url: w.linear.url }); + if (w.evidence && !links.some((l) => l.url === w.evidence!.url)) links.push(w.evidence); + return links; +}; + +// In progress first, then planned, then done; entries with any other status are not shown. +export function Work({ work }: { work: WorkItem[] }) { + const rows = work.filter((w) => WORK_STATUSES.includes(w.status)).toSorted((a, b) => WORK_STATUSES.indexOf(a.status) - WORK_STATUSES.indexOf(b.status)); + return ( +
    + Work log + {rows.length === 0 ? ( + + ) : ( + +
    + + + {['Status', 'Work', 'Impact category', 'Effect', 'Links'].map((h) => )} + + + + {rows.map((w) => { + const effect = w.effect ?? w.expected ?? null; + const about = [w.hypothesis && `Hypothesis: ${w.hypothesis}`, w.change && `Change: ${w.change}`, `Owner: ${w.owner ?? 'none assigned'}`].filter(Boolean); + const links = linksOf(w); + return ( + + + + + + + + ); + })} + +
    {h}
    + {STATUS[w.status]} + + + {w.title} + {about.join(' ')} + + {w.category ?? (w.area ? CATEGORY[w.area] : '—')}{effect ?? 'not measured yet'} + {links.length === 0 ? — : links.map((l, i) => ( + + {i > 0 ? ' · ' : null} + {l.label} + + ))} +
    + + )} +
    + ); +} diff --git a/app/scaling/components/WorkloadSubsystems.tsx b/app/scaling/components/WorkloadSubsystems.tsx new file mode 100644 index 0000000..56bf746 --- /dev/null +++ b/app/scaling/components/WorkloadSubsystems.tsx @@ -0,0 +1,265 @@ +import { Card } from '../../components/ui/Card'; +import { cn } from '../../components/ui/cn'; +import { Text } from '../../components/ui/Text'; +import { BLOB_DATA_BYTES, BLOB_TARGET, fmt, L1_SLOT_SECONDS } from '../da/workloads'; +import { successfulTps, type ProvenCapacity, type Run, type Workload } from '../proven/capacity'; +import type { Snapshot } from '../snapshot.mjs'; +import { bytes, diskLimits, times, type Limit } from './Bottlenecks'; +import { EXECUTE_PAYLOAD_P99_MS, PROOF_LAG_P99_BLOCKS, REPLAY_BLOCKS_PER_SECOND } from './Guardrails'; +import { Info } from './Info'; + +const COLUMNS: [Workload, string, string][] = [['payment', 'Pay', 'Payments (B20)'], ['swap', 'Swap', 'Swaps (Uniswap v3)'], ['mix', 'Mix', 'Mainnet mix (bench run)']]; +const NAME: Record = { payment: 'payments', swap: 'swaps', mix: 'mix' }; +const YEAR_SECONDS = 31_557_600; +const NO_RUN = 'no proven run for this workload'; +const NOT_IN_RUN = 'not in this run'; +const NO_STORAGE = 'needs a run with storage measurement'; + +type Cell = { value: string; sub?: string[]; bad?: boolean } | { why: string }; +type Row = { label: string; help: string; cells: Record }; +type Category = 'da' | 'disk-archive' | 'disk-full' | 'proof-node' | 'validators' | 'execution'; +type SubsystemCard = { id: Category; title: string; takeaway: string; bad: boolean; rows: Row[] }; + +const and = (items: string[]) => (items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`); +const ms = (n: number) => `${Math.round(n)} ms`; +const size = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${(n / 1e3).toFixed(1)} KB`); +const seconds = (msValue: number) => (msValue % 1000 === 0 ? `${msValue / 1000} s` : `${msValue} ms`); +// " at 400" when every run shares a rate; otherwise each cell is at its own run's rate. +const atRate = (runs: (Run | undefined)[]) => { + const rates = [...new Set(runs.filter((r) => r !== undefined).map((r) => r.mgas))]; + return rates.length === 1 ? ` at ${rates[0]}` : rates.length > 1 ? ' at each run’s rate' : ''; +}; +const cells = (runs: Record, value: (run: Run) => Cell, missing = NO_RUN) => + Object.fromEntries(COLUMNS.map(([w]) => [w, runs[w] ? value(runs[w]) : { why: missing }])) as Record; + +// DA ceiling at the blob target with the run's own compressed bytes per gas, as in the ranked table. +const daCeiling = ({ result }: NonNullable) => (BLOB_TARGET * BLOB_DATA_BYTES) / L1_SLOT_SECONDS / (result.frame_bytes / result.gas_used) / 1e6; +// The posting window's blobs per slot, rescaled from its gas rate to the run's load rate. +const blobsAt = (run: Run) => Math.round((run.da!.result.blobs_per_l1_slot * run.mgas) / (run.da!.result.gas_per_second / 1e6)); + +function daCard(da: ProvenCapacity['da'], gasLimit: number | null): SubsystemCard { + const runs = Object.fromEntries(COLUMNS.map(([w]) => [w, da[w]?.da ? da[w]! : undefined])) as Record; + const measured = COLUMNS.filter(([w]) => runs[w]); + const binding = measured.filter(([w]) => gasLimit !== null && daCeiling(runs[w]!.da!) < gasLimit).map(([w]) => NAME[w]); + return { + id: 'da', + title: 'DA', + takeaway: !measured.length ? 'not measured yet' : binding.length ? `binding for ${and(binding)}` : gasLimit === null ? 'no gas limit to compare against' : `below target at the ${gasLimit} Mgas/s gas limit`, + bad: binding.length > 0, + rows: [ + { + label: `Blobs per L1 slot${atRate(Object.values(runs))}`, + help: `Blobs each L1 slot needs at the run's load rate: the batch posting window's average blobs per slot, scaled from the window's gas rate to the load rate. The target is ${BLOB_TARGET}, shared by all rollups.`, + cells: cells(runs, (r) => ({ value: fmt(blobsAt(r)) }), NOT_IN_RUN), + }, + { + label: 'Ceiling (Mgas/s)', + help: `Gas rate that fills the ${BLOB_TARGET}-blob target: ${BLOB_TARGET} × ${BLOB_DATA_BYTES.toLocaleString('en-US')} bytes per blob ÷ ${L1_SLOT_SECONDS} s per slot ÷ the run's compressed bytes per gas. Mix is the bench run's mainnet mix, as in What breaks first.`, + cells: cells(runs, (r) => ({ value: fmt(daCeiling(r.da!)), bad: gasLimit !== null && daCeiling(r.da!) < gasLimit }), NOT_IN_RUN), + }, + ], + }; +} + +function diskCards(runs: Record, snapshot: Pick): SubsystemCard[] { + const withStorage = Object.values(runs).filter((r) => r?.storage); + const runway = snapshot.policy?.runwayYears; + const state = snapshot.production?.stateBytesPerGas?.value; + const storageCells = (value: (run: Run) => Cell) => cells(runs, (r) => (r.storage ? value(r) : { why: NO_STORAGE })); + const ceiling = (id: string) => (r: Run): Cell => { + const limit = diskLimits({ storage: r.storage!, run: r }, snapshot).find((l) => l.id === id)!; + return limit.value === null ? { why: limit.evidence } : limit.value === 0 ? { value: 'over budget', bad: true } : { value: limit.ceiling }; + }; + const card = (id: Category, title: string, years: number | undefined, rows: Row[]): SubsystemCard => ({ + id, title, rows, bad: false, + takeaway: !withStorage.length ? 'not measured yet' : years ? `${years}-year runway` : 'no runway policy', + }); + const ceilingLabel = (years: number | undefined) => `Ceiling${years ? ` (${years}-year runway)` : ''}`; + const ceilingHelp = 'Highest gas rate at which the disk budget holds the runway, from What breaks first. An upper bound: bench bytes per gas is a lower bound, because the sequencer prunes history.'; + return [ + card('disk-archive', 'Disk · archive', runway?.archive, [ + { + label: `Growth${atRate(withStorage)}`, + help: `Archive growth per year at the run's rate: the run's archive bytes per gas${state ? ` plus ${state} bytes per gas of mainnet state growth` : ''}. A lower bound, because the sequencer prunes history.`, + cells: storageCells((r) => ({ value: `≥ ${bytes((r.storage!.bytesPerGas.archive + (state ?? 0)) * r.mgas * 1e6 * YEAR_SECONDS)}/yr` })), + }, + { label: ceilingLabel(runway?.archive), help: ceilingHelp, cells: storageCells(ceiling('disk-archive')) }, + ]), + card('disk-full', 'Disk · full node', runway?.fullNode, [ + { + label: `Size${atRate(withStorage)}`, + help: 'History and proofs a full node keeps at the run\'s rate: history bytes per gas over the history window plus proof bytes per gas over the proof window.', + cells: storageCells(({ mgas, storage }) => { + const { bytesPerGas: b, fullNode: f } = storage!; + return b.proofs === null ? { why: NOT_IN_RUN } : { value: bytes(mgas * 1e6 * (b.history * f.historyWindowSeconds + b.proofs * f.proofWindowSeconds)) }; + }), + }, + { label: ceilingLabel(runway?.fullNode), help: ceilingHelp, cells: storageCells(ceiling('disk-full')) }, + ]), + ]; +} + +function proofCard(runs: Record): SubsystemCard { + const proofs = (r: Run) => r.proofNode?.proofs ?? null; + const execute = (r: Run) => proofs(r)?.methods.debug_executePayload?.latency.p99Ms; + const measured = COLUMNS.filter(([w]) => runs[w] && proofs(runs[w])); + const lagOver = measured.filter(([w]) => proofs(runs[w]!)!.headLagBlocks.p99 > PROOF_LAG_P99_BLOCKS).map(([w]) => NAME[w]); + const executeOver = measured.filter(([w]) => (execute(runs[w]!) ?? 0) > EXECUTE_PAYLOAD_P99_MS).map(([w]) => NAME[w]); + const over = [ + lagOver.length && `lag over ${PROOF_LAG_P99_BLOCKS} for ${and(lagOver)}`, + executeOver.length && `executePayload over ${seconds(EXECUTE_PAYLOAD_P99_MS)} for ${and(executeOver)}`, + ].filter((s): s is string => !!s); + const proofCells = (value: (p: NonNullable>, r: Run) => Cell) => cells(runs, (r) => { + const p = proofs(r); + return p ? value(p, r) : { why: NOT_IN_RUN }; + }); + return { + id: 'proof-node', + title: 'Proof node', + takeaway: !measured.length ? 'not measured yet' : over.length ? over.join('; ') : `within budget (lag ≤ ${PROOF_LAG_P99_BLOCKS}, ≤ ${seconds(EXECUTE_PAYLOAD_P99_MS)})`, + bad: over.length > 0, + rows: [ + { + label: 'Lag p99 (max)', + help: `Blocks the proof node trails the sequencer's head, p99 and max over the run. Budget: p99 ≤ ${PROOF_LAG_P99_BLOCKS}.`, + cells: proofCells(({ headLagBlocks: l }) => ({ value: `${l.p99} (${l.max})`, bad: l.p99 > PROOF_LAG_P99_BLOCKS })), + }, + { + label: 'executePayload p99', + help: `p99 latency of debug_executePayload on the proof node. Budget: ≤ ${seconds(EXECUTE_PAYLOAD_P99_MS)}.`, + cells: proofCells((_, r) => { + const p99 = execute(r); + return p99 === undefined ? { why: NOT_IN_RUN } : { value: ms(p99), bad: p99 > EXECUTE_PAYLOAD_P99_MS }; + }), + }, + { + label: 'Witness p50', + help: 'Median execution witness size the proof node serves per block.', + cells: proofCells(({ witnessBytes: w }) => (w.samples ? { value: size(w.p50) } : { why: NOT_IN_RUN })), + }, + ], + }; +} + +function validatorCard(runs: Record): SubsystemCard { + const measured = COLUMNS.filter(([w]) => runs[w]?.replay); + const behind = measured.filter(([w]) => !runs[w]!.replay!.match || runs[w]!.replay!.blocksPerSecond < REPLAY_BLOCKS_PER_SECOND).map(([w]) => NAME[w]); + const replayCells = (value: (r: NonNullable) => Cell) => cells(runs, (r) => (r.replay ? value(r.replay) : { why: NOT_IN_RUN })); + return { + id: 'validators', + title: 'Validators', + takeaway: !measured.length ? 'not measured yet' : behind.length ? `${and(behind)} ${behind.length === 1 ? 'does' : 'do'} not keep up` : 'all keep up', + bad: behind.length > 0, + rows: [ + { + label: 'Replay speed', + help: `How many times faster than real time a validator replays the run's blocks: blocks replayed per second ÷ the ${REPLAY_BLOCKS_PER_SECOND} the chain produces. Below 1× it falls behind.`, + cells: replayCells((r) => ({ value: times(r.blocksPerSecond / REPLAY_BLOCKS_PER_SECOND), bad: !r.match || r.blocksPerSecond < REPLAY_BLOCKS_PER_SECOND })), + }, + { + label: 'newPayload p50 / p99', + help: 'Validator engine_newPayload latency during replay, p50 and p99.', + cells: replayCells(({ newPayload: n }) => ({ value: `${Math.round(n.p50Ms)} / ${ms(n.p99Ms)}` })), + }, + ], + }; +} + +function sequencerCard(runs: Record): SubsystemCard { + const rates = [...new Set(Object.values(runs).flatMap((r) => (r ? [r.mgas] : [])))].sort((a, b) => a - b); + return { + id: 'execution', + title: 'Sequencer', + takeaway: !rates.length ? 'no proven run' : `${rates.length === 1 ? rates[0] : `${rates[0]}–${rates.at(-1)}`} delivered`, + bad: false, + rows: [ + { + label: 'Delivered (Mgas/s)', + help: 'Gas rate the load test delivered, and successful transactions per second; reverted transactions use gas but are not counted.', + cells: cells(runs, (r) => ({ + value: (r.gps / 1e6).toFixed(1), + sub: [`${fmt(successfulTps(r))} tx/s`, ...(r.reverted > 0.01 ? [`${Math.round(r.reverted * 100)}% reverted`] : [])], + })), + }, + { + label: 'Block latency p50 / p99', + help: 'Time from sending a transaction to seeing it in a block, from the load test.', + cells: cells(runs, (r) => (r.blockLatency ? { value: `${Math.round(r.blockLatency.p50Ms)} / ${ms(r.blockLatency.p99Ms)}` } : { why: NOT_IN_RUN })), + }, + ], + }; +} + +const MATCHES: Record boolean> = { + da: (id) => id.startsWith('da-'), + 'disk-archive': (id) => id === 'disk-archive', + 'disk-full': (id) => id === 'disk-full', + 'proof-node': (id) => id === 'proof-node' || id === 'core', + validators: (id) => id === 'validators' || id === 'core', + execution: (id) => id === 'execution' || id === 'core', +}; + +// Cards follow the ranked table: a category's first ranked row sets its place; unranked ones keep the default order. +export function subsystemCards(data: ProvenCapacity | undefined, limits: Limit[], snapshot: Pick, gasLimit: number | null): SubsystemCard[] { + const runs = Object.fromEntries(COLUMNS.map(([w]) => [w, data?.current[w]?.run])) as Record; + const da = data?.da ?? { mix: null, payment: null, swap: null }; + const cards = [daCard(da, gasLimit), ...diskCards(runs, snapshot), proofCard(runs), validatorCard(runs), sequencerCard(runs)]; + const rank = (c: SubsystemCard) => { + const i = limits.findIndex((l) => l.value !== null && MATCHES[c.id](l.id)); + return i < 0 ? Infinity : i; + }; + return cards.toSorted((a, b) => rank(a) - rank(b)); +} + +const TH = 'px-2 py-1.5 min-[760px]:px-3 text-right text-[11px] font-[600] uppercase tracking-wide text-bds-gray-60'; +const TD = 'px-2 py-1.5 min-[760px]:px-3'; + +function Value({ cell }: { cell: Cell }) { + if ('why' in cell) { + return — ({cell.why}); + } + return ( + <> + {/* Each side of " / " stays whole; narrow cards break between them. */} + {cell.value.split(' / ').map((part, i) => {i > 0 ? ' / ' : null}{part})} + {cell.sub?.map((s) => {s})} + + ); +} + +export function WorkloadSubsystems({ cards }: { cards: SubsystemCard[] }) { + return ( +
    + Subsystems by workload +
    + {cards.map((c) => ( + +

    + {c.title} · {c.takeaway} +

    +
    + + + + + {COLUMNS.map(([w, short, full]) => )} + + + + {c.rows.map((r) => ( + + + {COLUMNS.map(([w]) => )} + + ))} + +
    Metric{short}
    + {r.label} {r.help} +
    +
    +
    + ))} +
    +
    + ); +} diff --git a/app/scaling/components/firstScreen.test.tsx b/app/scaling/components/firstScreen.test.tsx index 44a8d72..419b518 100644 --- a/app/scaling/components/firstScreen.test.tsx +++ b/app/scaling/components/firstScreen.test.tsx @@ -3,10 +3,11 @@ import { readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; +import type { Run } from '../proven/capacity'; import type { Snapshot } from '../snapshot.mjs'; -import { CeilingCards } from './CeilingCards'; -import { Guardrails } from './Guardrails'; -import { Progress } from './Progress'; +import { AREAS } from './CeilingCards'; +import { RunBackedCeilingCard } from './RunBackedCeilingCard'; +import { Guardrails, scorecardGuardrails } from './Guardrails'; // Synthetic data only: real snapshots are internal and never committed. function fixture(): Snapshot { @@ -15,9 +16,9 @@ function fixture(): Snapshot { const cards = (html: string) => html.split(' { +describe('CeilingCard', () => { it('renders every area but networking in a fixed order with its tag, and badges the binding card', () => { - const html = cards(renderToStaticMarkup()); + const html = cards(renderToStaticMarkup(<>{AREAS.map(([area]) => )})); expect(html.map((c) => c.match(/href="\/scaling\/(\w+)"/)![1])).toEqual(['da', 'execution', 'state']); expect(html.map((c) => c.match(/border-dashed[^>]*>([^<]+) c.includes('>Binding<'))).toEqual([true, false, false]); @@ -26,7 +27,7 @@ describe('CeilingCards', () => { it('moves the binding badge to a lower measured ceiling, and shows a dash, reason and pending inputs when there is no value', () => { const { ceilings } = fixture(); ceilings[1] = { ...ceilings[1], value: 90, tag: 'measured' }; - const html = cards(renderToStaticMarkup()); + const html = cards(renderToStaticMarkup(<>{AREAS.map(([area]) => )})); expect(html.map((c) => c.includes('>Binding<'))).toEqual([false, true, false]); expect(html[2]).toContain('>—<'); expect(html[2]).toContain('live-state growth not measured'); @@ -36,47 +37,72 @@ describe('CeilingCards', () => { }); describe('Guardrails', () => { - it('shows comparator and unit with the threshold, tags provisional and unset thresholds, and colors only known statuses', () => { - const rows = renderToStaticMarkup().split('Provisional<'); + const detailed = () => renderToStaticMarkup(); + const labels = (html: string) => html.split(' r.match(/]*>([^<]+) { + const [shown] = detailed().split('Unset<'); - expect(rows[3]).toContain('bg-bds-gray-30'); - expect(rows[3]).not.toMatch(/bg-bds-(green|red)/); + expect(rows[2]).toContain('bg-bds-green-50'); }); - it('sorts rows with a value first, then unknown, then muted not-monitored rows', () => { - const html = renderToStaticMarkup(); - const rows = html.split(' r.match(/]*>(?:]*>)?([^<]+?) ?Unknown'); - expect(rows[5]).toContain('text-bds-gray-60'); - expect(rows[5]).toContain('Not monitored'); - expect(rows.slice(0, 5).join('')).not.toContain('Not monitored'); + it('collapses unknown and not-monitored rows under a count, tagging unset thresholds', () => { + const html = detailed(); + const collapsed = html.split('Unset<'); + expect(collapsed).toContain('Not monitored'); + expect(collapsed).not.toMatch(/bg-bds-(green|red)/); + expect(html).toContain('Usage data as of 2026-01-02; bench rows show their run.'); }); }); -describe('Progress', () => { - it('shows Linear state and a labelled bar when linked, and proposed with no bar otherwise', () => { - const [linked, proposed] = renderToStaticMarkup().split('px-4 py-3').slice(1); - expect(linked).toContain('>started<'); - expect(linked).toContain('aria-label="4 of 10 issues done"'); - expect(linked).toContain('width:40%'); - expect(linked).toContain('Realized:'); - expect(linked).toContain('href="https://example.com/synthetic/linear/project"'); - expect(proposed).toContain('>proposed<'); - expect(proposed).not.toContain('progressbar'); - expect(proposed).not.toContain('Realized:'); +describe('scorecardGuardrails', () => { + const LATENCY = { p50Ms: 2, p99Ms: 9, maxMs: 12 }; + const run = (blocksPerSecond: number, lagP99 = 4, executeP99 = 9): Run => ({ + id: 'synthetic', outputDir: 'synthetic-400', workload: 'mix', mgas: 400, gps: 4e8, tps: 6000, reverted: 0, storage: null, blockLatency: null, passed: true, commit: 'c0ffee', + createdAt: Date.parse('2026-01-05T00:00:00Z'), steps: [], da: null, + replay: { blocksPerSecond, newPayload: LATENCY, match: true }, + proofNode: { + blocksPerSecond: 20, newPayload: LATENCY, match: true, + proofs: { headLagBlocks: { samples: 10, p50: 1, p99: lagP99, max: 20 }, failedProbes: 0, methods: { debug_executePayload: { errors: 0, latency: { ...LATENCY, p99Ms: executeP99 } } }, witnessBytes: { samples: 0, p50: 0, p99: 0, max: 0 } }, + }, + }); + const byId = (rows: ReturnType) => Object.fromEntries(rows.map((g) => [g.id, g])); + + it('judges validator keep-up against 3 blocks, unknown without a snapshot value', () => { + const guardrails = fixture().guardrails; + expect(byId(scorecardGuardrails(guardrails, undefined))['validator-keep-up']).toMatchObject({ value: 3, threshold: 3, thresholdTag: 'set', status: 'ok' }); + const breached = guardrails.map((g) => (g.id === 'validator-keep-up' ? { ...g, value: 4 } : g)); + expect(byId(scorecardGuardrails(breached, undefined))['validator-keep-up'].status).toBe('breached'); + const rows = scorecardGuardrails(guardrails.filter((g) => g.id !== 'validator-keep-up'), undefined); + expect(rows.at(-1)).toMatchObject({ id: 'validator-keep-up', value: null, threshold: 3, status: 'unknown' }); }); - it('hides realized without evidence', () => { - const [item] = fixture().work; - expect(renderToStaticMarkup()).not.toContain('Realized:'); + it('adds bench rows only with a bench host: proof-node lag ≤ 10 blocks, executePayload ≤ 1000 ms, replay headroom against 5 blocks/s', () => { + expect(scorecardGuardrails(fixture().guardrails, undefined)).toHaveLength(6); + const rows = byId(scorecardGuardrails(fixture().guardrails, run(36.4))); + expect(rows['proof-node-head-lag']).toMatchObject({ value: 4, unit: 'blocks', threshold: 10, comparator: '<=', thresholdTag: 'set', status: 'ok', asOf: '2026-01-05T00:00:00.000Z' }); + expect(rows['proof-node-head-lag'].help).toContain('From bench run synthetic-400.'); + expect(rows['proof-node-execute-payload']).toMatchObject({ value: 9, unit: 'ms', threshold: 1000, thresholdTag: 'set', status: 'ok' }); + const over = byId(scorecardGuardrails(fixture().guardrails, run(36.4, 11, 1001))); + expect(over['proof-node-head-lag'].status).toBe('breached'); + expect(over['proof-node-execute-payload'].status).toBe('breached'); + expect(rows['validator-replay-headroom']).toMatchObject({ value: 36.4, threshold: 5, comparator: '>=', status: 'ok' }); + expect(byId(scorecardGuardrails(fixture().guardrails, run(4)))['validator-replay-headroom'].status).toBe('breached'); + }); + + it('shows bench rows as unknown when no qualifying run is loaded', () => { + const rows = scorecardGuardrails(fixture().guardrails, null).slice(-3); + expect(rows.map((g) => [g.value, g.status])).toEqual([[null, 'unknown'], [null, 'unknown'], [null, 'unknown']]); + expect(rows[0].help).toContain('No qualifying mainnet-mix bench run is loaded.'); }); }); + diff --git a/app/scaling/components/ladderLayout.test.ts b/app/scaling/components/ladderLayout.test.ts deleted file mode 100644 index 9b073c7..0000000 --- a/app/scaling/components/ladderLayout.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { layoutRungs } from './ladderLayout'; - -const rungs = (values: number[]) => values.map((value, i) => ({ id: `r${i}`, value })); - -describe('layoutRungs', () => { - it('keeps sparse rungs on the first tier, alternating sides in value order', () => { - const placed = layoutRungs(rungs([40, 200, 12.5, 95]), 200, 0.15); - expect(placed.map((p) => p.id)).toEqual(['r2', 'r0', 'r3', 'r1']); - expect(placed.map((p) => p.side)).toEqual(['above', 'below', 'above', 'below']); - expect(placed.every((p) => p.tier === 0)).toBe(true); - }); - - it('moves a rung to the next free tier on its side when its box would overlap', () => { - const placed = layoutRungs(rungs([10, 12, 14, 16, 18]), 200, 0.2); - expect(placed.map((p) => [p.side, p.tier])).toEqual([ - ['above', 0], ['below', 0], ['above', 1], ['below', 1], ['above', 2], - ]); - }); - - it('returns to the first tier once the previous label on that side ends', () => { - const placed = layoutRungs(rungs([10, 20, 30, 150]), 200, 0.2); - expect(placed.map((p) => p.tier)).toEqual([0, 0, 1, 0]); - }); - - it('anchors labels near the ends so they stay inside the axis, and clamps values above the limit', () => { - const [low, mid, high] = layoutRungs(rungs([5, 100, 250]), 200, 0.2); - expect(low).toMatchObject({ x: 0.025, anchor: 'start' }); - expect(mid).toMatchObject({ x: 0.5, anchor: 'middle' }); - expect(high).toMatchObject({ x: 1, anchor: 'end' }); - }); -}); diff --git a/app/scaling/components/ladderLayout.ts b/app/scaling/components/ladderLayout.ts deleted file mode 100644 index 49511e2..0000000 --- a/app/scaling/components/ladderLayout.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type Anchor = 'start' | 'middle' | 'end'; -export type Placement = { id: string; x: number; side: 'above' | 'below'; tier: number; anchor: Anchor }; - -// Positions along a 0–1 axis; `labelWidth` shares that unit. Rungs alternate sides in value -// order; a label takes the first tier on its side where it does not overlap the previous label. -export function layoutRungs(rungs: { id: string; value: number }[], max: number, labelWidth: number): Placement[] { - const rightEdges: Record = { above: [], below: [] }; - return [...rungs] - .sort((a, b) => a.value - b.value) - .map((r, i) => { - const x = Math.min(1, r.value / max); - const anchor: Anchor = x < labelWidth / 2 ? 'start' : x > 1 - labelWidth / 2 ? 'end' : 'middle'; - const left = anchor === 'start' ? x : anchor === 'end' ? x - labelWidth : x - labelWidth / 2; - const side = i % 2 === 0 ? 'above' : 'below'; - const edges = rightEdges[side]; - let tier = edges.findIndex((edge) => edge <= left); - if (tier === -1) tier = edges.length; - edges[tier] = left + labelWidth; - return { id: r.id, x, side, tier, anchor }; - }); -} diff --git a/app/scaling/components/provenCapacity.test.tsx b/app/scaling/components/provenCapacity.test.tsx deleted file mode 100644 index 37e9ed5..0000000 --- a/app/scaling/components/provenCapacity.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { readFileSync } from 'node:fs'; - -import type { ReactNode } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { Bench } from './ProvenCapacity'; -import type { Run } from '../proven/capacity'; -import type { Snapshot } from '../snapshot.mjs'; -import { Ladder } from './Ladder'; - -// Synthetic data only: real bench results and snapshots are internal and never committed. -const swr = vi.hoisted(() => ({ result: {} as object, key: undefined as unknown })); -vi.mock('swr', () => ({ default: (key: unknown) => ((swr.key = key), key === null ? {} : swr.result) })); -vi.mock('./Info', () => ({ Info: ({ children }: { children: ReactNode }) => {children} })); - -const BENCH = 'https://bench.example.com'; -const DAY = 86_400_000; -const T0 = Date.parse('2026-01-01T00:00:00Z'); -const run = (mgas: number): Run => ({ outputDir: `synthetic-${mgas}`, mgas, gps: (mgas - 5) * 1e6, passed: true, commit: 'bbbbbbbbbbbbbbbb', createdAt: T0 + 5 * DAY }); -const bench = (mgas: number, atTop: boolean): Bench => ({ - current: { mgas, atTop, run: run(mgas) }, - history: [ - { at: T0, mgas: 40, commit: 'aaaaaaaaaaaaaaaa', previousCommit: null, compareUrl: null }, - { at: T0 + 5 * DAY, mgas, commit: 'bbbbbbbbbbbbbbbb', previousCommit: 'aaaaaaaaaaaaaaaa', compareUrl: 'https://github.com/base/base/compare/aaaa...bbbb' }, - ], - unknown: [], - now: T0 + 7 * DAY, -}); -const snapshot: Snapshot = JSON.parse(readFileSync(new URL('../fixtures/synthetic.v2.json', import.meta.url), 'utf8')); -const render = (props: { benchUrl?: string; dashboardUrl?: string } = { benchUrl: BENCH }) => - renderToStaticMarkup(); -// A placed rung renders its label, help and value; an unplaced one is a list item with its tag. -const rung = (html: string, label: string) => html.match(new RegExp(`${label}(?:(?!
  • ).)*?(.*?)`))?.[1]; -const ceilingLabel = (html: string) => html.match(/Ceiling · (\w+)/)?.[1]; - -beforeEach(() => { - swr.result = {}; - swr.key = undefined; -}); - -describe('Ladder bench capacity', () => { - it('keeps the snapshot rungs and fetches nothing without a URL', () => { - const html = render({}); - expect(swr.key).toBeNull(); - expect(html).toMatch(/Qualified sustained<\/span>.*Not established/); - expect(ceilingLabel(html)).toBe('DA'); - expect(html).not.toContain('bench results'); - }); - - it('keeps the snapshot rungs while loading and after a failed fetch', () => { - expect(render()).toContain('Loading bench results'); - expect(swr.key).toEqual(['scaling-proven-capacity', BENCH]); - swr.result = { error: new TypeError('Failed to fetch'), data: bench(400, true) }; - const html = render(); - expect(html).toContain('VPN'); - expect(html).toMatch(/Qualified sustained<\/span>.*Not established/); - expect(html).not.toContain('bench'); - }); - - it('shows ≥ at the top rate with bench provenance, the rule, run, commit, caveat, evidence and chart', () => { - swr.result = { data: bench(400, true) }; - const html = render(); - expect(rung(html, 'Qualified sustained')).toMatch(/^≥ 400]*>measured · bench$/); - for (const text of ['latest main run in the last 14 days was comparable and delivered ≥ 95% of target', 'Run synthetic-400, commit bbbbbbb, 2026-01-06.', '400 Mgas/s is the highest rate the bench runs', 'DA and proof guardrails are not checked']) { - expect(html).toContain(text); - } - expect(html).toContain('Qualified by synthetic-400: 400 Mgas/s run delivered 395.0 Mgas/s'); - expect(html).toContain('aria-label="Proven capacity over time"'); - expect(html).toContain('40 Mgas/s → 400 Mgas/s'); - expect(ceilingLabel(html)).toBe('DA'); - }); - - it('drops ≥ below the top rate, and the bench execution ceiling binds when it is the lowest', () => { - swr.result = { data: bench(80, false) }; - const html = render(); - expect(rung(html, 'Qualified sustained')).toMatch(/^80]*>measured · bench$/); - expect(ceilingLabel(html)).toBe('execution'); - expect(rung(html, 'Ceiling · execution')).toMatch(/^80]*>measured · bench$/); - expect(html).not.toContain('highest rate the bench runs'); - }); - - it('shows an empty state when no run counts, and warns when run files failed to load', () => { - swr.result = { data: { current: null, history: [], unknown: [], now: T0 } }; - expect(render()).toContain('No main run passed in the last 14 days.'); - swr.result = { data: { current: null, history: [], unknown: ['r1', 'r2'], now: T0 } }; - expect(render()).toContain('2 run(s) could not be loaded'); - expect(render()).not.toContain('No main run passed'); - }); - - it('links the dashboard only when set', () => { - expect(render({ dashboardUrl: 'https://dashboards.example.com/scaling' })).toContain('href="https://dashboards.example.com/scaling">Mainnet load, gas limit, DA ceiling →'); - expect(render()).not.toContain('Mainnet load'); - }); -}); diff --git a/app/scaling/components/runBacked.test.tsx b/app/scaling/components/runBacked.test.tsx index 445a16e..ae565cd 100644 --- a/app/scaling/components/runBacked.test.tsx +++ b/app/scaling/components/runBacked.test.tsx @@ -6,10 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import runFixture from '../runs/fixtures/synthetic.json'; import { getTestRunsWithStatus, type BenchmarkRuns } from '../runs/lib/types'; import { toUnifiedRun, type LoadRun } from '../runs/lib/unified'; +import type { Run } from '../proven/capacity'; import type { Snapshot } from '../snapshot.mjs'; -import { CeilingCards } from './CeilingCards'; -import { Ladder } from './Ladder'; -import { Progress } from './Progress'; +import { AREAS } from './CeilingCards'; +import { RunBackedCeilingCard } from './RunBackedCeilingCard'; +import { DaWorkloads } from './DaWorkloads'; import { RelatedRuns } from './RelatedRuns'; // Synthetic data only: real snapshots and runs are internal and never committed. @@ -32,7 +33,7 @@ describe('execution ceiling', () => { swr.byKey['scaling-bench-metadata'] = { data: { runs: [older, bench, fasterConfig, metadata.runs[1]] } }; const { ceilings } = snapshot(); ceilings[0].value = 70; // DA modeled above the run's 48 Mgas/s. - const [da, execution] = cards(renderToStaticMarkup()); + const [da, execution] = cards(renderToStaticMarkup(<>{AREAS.map(([area]) => )})); expect(execution).toMatch(/>48<\/span>.*?>Estimated { it('falls back to the snapshot when the run API is unavailable', () => { swr.byKey['scaling-bench-metadata'] = { error: new Error('not configured') }; - const [, execution] = cards(renderToStaticMarkup()); + const [, execution] = cards(renderToStaticMarkup(<>{AREAS.map(([area]) => )})); expect(execution).toMatch(/>150<\/span>.*?>Estimated { swr.byKey['scaling-bench-metadata'] = { data: metadata }; const { ceilings } = snapshot(); Object.assign(ceilings[1], { value: 60, tag: 'measured' }); - const [, execution] = cards(renderToStaticMarkup()); + const [, execution] = cards(renderToStaticMarkup(<>{AREAS.map(([area]) => )})); expect(execution).toMatch(/>60<\/span>.*?>Measured<.*?>Binding { - const { ladder, ceilings, translations, windows } = snapshot(); - ladder.qualified = { ...ladder.qualified, value: 30, tag: 'measured', evidence: [{ label: 'Synthetic run', url: 'https://example.com/scaling/runs/bench/synthetic-1' }] }; - const html = renderToStaticMarkup(); - expect(html).toContain('Qualified sustained by Synthetic run'); -}); - it('lists the newest runs of each kind covering an area', () => { const nightly = Array.from({ length: 6 }, (_, i) => ({ network: 'sepolia', timestamp: `2026-02-0${i + 1}T00-00-00` })); const runs = [...getTestRunsWithStatus(metadata), ...(runFixture.loadTests as unknown as LoadRun[]), ...nightly].map(toUnifiedRun).sort((a, b) => b.timestamp - a.timestamp); @@ -76,12 +70,31 @@ it('lists the newest runs of each kind covering an area', () => { expect(links('state')).toEqual([]); }); -it('shows gas/s and status for evidence that points at a run page', () => { - swr.byKey['scaling-bench-metadata'] = { data: metadata }; - swr.byKey['scaling-load-result'] = { data: runFixture.loadTests[0].result }; - const [item] = snapshot().work; - const at = (url: string) => renderToStaticMarkup(); - expect(at('https://example.com/scaling/runs/bench/test-synthetic-1')).toMatch(/42\.0 Mgas\/s.*?success/); - expect(at('https://example.com/scaling/runs/load/sepolia/2026-01-08T00-00-01')).toMatch(/12\.5 Mgas\/s.*?success/); - expect(at('https://example.com/synthetic/doc')).not.toContain('Mgas/s'); +describe('DA from bench runs', () => { + const da = (frame_bytes: number, withinTarget: boolean | null): Run['da'] => ({ + result: { seconds: 600, gas_used: 4e10, transactions: 1e6, transaction_bytes: 0, frame_bytes, gas_per_second: 1.9e8, bytes_per_gas: 0, compression_ratio: 0, blobs_per_l1_slot: 15.5, blob_fill: 1, blob_target_gas_per_second: 0 }, + target: 14, + withinTarget, + }); + const run = (workload: Run['workload'], runDa: Run['da']): Run => ({ + id: `synthetic-${workload}`, outputDir: `synthetic-${workload}`, workload, mgas: 200, gps: 2e8, tps: 4000, reverted: 0, storage: null, blockLatency: null, passed: true, + commit: 'c0ffee', createdAt: Date.parse('2026-01-05T00:00:00Z'), steps: [], replay: null, proofNode: null, da: runDa, + }); + const rows = (html: string) => html.split(' { + const html = rows(renderToStaticMarkup()); + expect(html[0]).toMatch(/>~100<.*>Estimated<.*b20-transfer.*, pending/); + expect(html[0]).toContain('>—<'); + }); + + it('switches a payload to measured with its run link, and shows blobs per slot against the target', () => { + swr.byKey['scaling-proven-capacity'] = { data: { da: { payment: run('payment', da(8e7, false)), swap: null, mix: run('mix', da(4e7, null)) } } }; + const html = rows(renderToStaticMarkup()); + expect(html[0]).toMatch(/>80<.*>40,000<.*>Measured<.*href="\/scaling\/runs\/bench\/synthetic-payment"[^>]*>bench 200 Mgas\/s run]*>overEstimated<'); + expect(html[2]).toContain('>155<'); + expect(html[2]).toMatch(/15\.5 \/ 14<\/span>/); + }); }); diff --git a/app/scaling/components/scorecard.test.tsx b/app/scaling/components/scorecard.test.tsx new file mode 100644 index 0000000..e8c6edc --- /dev/null +++ b/app/scaling/components/scorecard.test.tsx @@ -0,0 +1,632 @@ +import { readFileSync } from 'node:fs'; + +import type { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ProvenCapacity, Run } from '../proven/capacity'; +import type { Snapshot } from '../snapshot.mjs'; +import { Guardrails } from './Guardrails'; +import { headroomLabel, place, sustainable } from './Headline'; +import { layoutPoints, type PlacedPoint, type Point } from './Ladder'; +import { Scorecard } from './Scorecard'; + +// Synthetic data only: real bench results and snapshots are internal and never committed. +const swr = vi.hoisted(() => ({ result: {} as object, key: undefined as unknown })); +vi.mock('swr', () => ({ default: (key: unknown) => ((swr.key = key), key === null ? {} : swr.result) })); +vi.mock('./Info', () => ({ Info: ({ children }: { children: ReactNode }) => {children} })); + +const BENCH = 'https://bench.example.com'; +const DAY = 86_400_000; +const T0 = Date.parse('2026-01-01T00:00:00Z'); +const LATENCY = { p50Ms: 20, p99Ms: 60, maxMs: 90 }; +const STEPS: Run['steps'] = [ + { name: 'sequencer', enabled: true, status: 'passed' }, + { name: 'validator_replay', enabled: true, status: 'passed' }, + { name: 'proof_node', enabled: true, status: 'passed' }, + { name: 'tee_proof', enabled: false, status: 'not-implemented', reason: 'needs a synthetic L1' }, +]; +const daResult = (frame_bytes: number, blobs: number, withinTarget: boolean | null): Run['da'] => ({ + result: { seconds: 600, gas_used: 4e10, transactions: 1e6, transaction_bytes: 0, frame_bytes, gas_per_second: 1.6e8, bytes_per_gas: frame_bytes / 4e10, compression_ratio: 4.5, blobs_per_l1_slot: blobs, blob_fill: 1, blob_target_gas_per_second: 0 }, + target: 14, + withinTarget, +}); +const run = (mgas: number, workload: Run['workload'] = 'mix', da: Run['da'] = null, lag = 4): Run => ({ + id: `synthetic-${workload}-${mgas}`, outputDir: `synthetic-${mgas}`, workload, mgas, gps: (mgas - 5) * 1e6, tps: mgas * 10.5, reverted: 0, storage: null, blockLatency: null, passed: true, + commit: 'bbbbbbbbbbbbbbbb', createdAt: T0 + 5 * DAY, steps: STEPS, da, + replay: { blocksPerSecond: 36.4, newPayload: LATENCY, match: true }, + proofNode: { + blocksPerSecond: 20, newPayload: LATENCY, match: true, + proofs: { headLagBlocks: { samples: 10, p50: 1, p99: lag, max: 20 }, failedProbes: 0, methods: { debug_executePayload: { errors: 0, latency: { p50Ms: 200, p99Ms: 733, maxMs: 900 } } }, witnessBytes: { samples: 0, p50: 0, p99: 0, max: 0 } }, + }, +}); +const none = { mix: null, payment: null, swap: null }; +const storage = (currentBytes: number | null = null): NonNullable => ({ + diskBudgetBytes: 45e12, + bytesPerGas: { history: 0.0086, proofs: 0.0045, archive: 0.0086 }, + current: { state: 1.1e12 }, + archive: { growthBytesPerYear: 5e12, growthBound: 'lower', currentBytes, runwayYears: null, runwayBound: 'upper' }, + fullNode: { + stateGrowthBytesPerYear: 1.05e10, historyWindowSeconds: 2_678_400, historyFootprintBytes: 9.2e12, historyFootprintBound: 'lower', + proofWindowSeconds: 1_296_000, proofFootprintBytes: 2.3e12, + }, +}); +const bench = (mgas: number, atTop: boolean, more: Partial = {}): ProvenCapacity => ({ + current: { ...none, mix: { mgas, atTop, run: run(mgas) } }, + da: none, + history: [ + { at: T0, mgas: 40, commit: 'aaaaaaaaaaaaaaaa', previousCommit: null, compareUrl: null }, + { at: T0 + 5 * DAY, mgas, commit: 'bbbbbbbbbbbbbbbb', previousCommit: 'aaaaaaaaaaaaaaaa', compareUrl: 'https://github.com/base/base/compare/aaaa...bbbb' }, + ], + latestAt: T0 + 5 * DAY, + unknown: [], + ...more, +}); +const fixture = (): Snapshot => JSON.parse(readFileSync(new URL('../fixtures/synthetic.v2.json', import.meta.url), 'utf8')); +// In page order: the freshness line, then headline, ladder, ranked limits, workloads, work. +const render = (props: { benchUrl?: string } = { benchUrl: BENCH }, snapshot = fixture()) => { + const html = renderToStaticMarkup(); + const [fresh, head, ladder, ranked, subsystems, work] = html.split(' { + let out = html; + for (let prev = ''; prev !== out; ) { + prev = out; + out = out.replace(/<[^>]*>/g, ''); + } + return out.replaceAll(''', "'"); +}; +// Table rows after a header row (skip 2) or without one (skip 1), without the mocked help popovers. +const rows = (html: string, skip = 2) => html.replace(/ ?[^<]*<\/span>/g, '').split(' text(` text(` c.includes(label)) ?? '>'}`.replace(/<[^>]*$/, '')); + +beforeEach(() => { + swr.result = {}; + swr.key = undefined; +}); + +describe('freshness and headline', () => { + it('dates usage and bench, dropping the bench part without bench data, and no SLOs', () => { + expect(text(render({}).fresh)).toBe('Usage to 2026-01-02'); + swr.result = { data: bench(400, true) }; + expect(text(render().fresh)).toBe('Usage to 2026-01-02 · bench 2026-01-06 00:00 UTC'); + expect(render().html).not.toContain('Usage data as of'); + }); + + it('heads with proven capacity, today and headroom only, leaving the next bottleneck and last change to What breaks first', () => { + swr.result = { data: bench(400, true) }; + const { head, ranked } = render(); + // 400 proven ÷ 12.5 today = 32×, a floor because 400 is the top rate tested. + expect(text(head)).toContain("≥ 400Mgas/s≥ 32×Proven capacity ÷ today's median gas rate. What breaks first lists the limits that apply before proven capacity.today 12.5· 64 tx/s"); + expect(text(head)).toContain('proven ≥ 400Payments (B20)'); + expect(text(head)).not.toMatch(/p99|mix DA|archive disk ≤/); + expect(text(head)).not.toMatch(/Next bottleneck|may cap lower|↑ from|First limit:|Payments per second|In transactions/); + // The last change sits on the run line under the ranked table. + expect(text(ranked)).toMatch(/Proven by.*Change↑ from 40 Mgas\/s on 2026-01-06 · compare/); + expect(ranked).toContain('href="https://github.com/base/base/compare/aaaa...bbbb"'); + }); + + it('falls back to the snapshot without a bench host, and says so while loading or failed', () => { + const snapshot = fixture(); + delete snapshot.usage; + snapshot.ladder.qualified = { ...snapshot.ladder.qualified, value: 30, tag: 'measured', evidence: [{ label: 'Synthetic run', url: 'https://example.com/scaling/runs/bench/synthetic-1' }] }; + const head = text(render({}, snapshot).head); + expect(head).toContain('30Mgas/s2.4×'); + expect(head).toContain('today 12.5proven 30'); + expect(head).toContain('Proven capacity from the snapshot · Synthetic run'); + expect(text(render().head)).toContain('Loading bench results…'); + swr.result = { error: new TypeError('Failed to fetch'), data: bench(400, true) }; + expect(text(render().head)).toMatch(/per second\.—Mgas\/s.*VPN/); + }); + +}); + +describe('workload tiles', () => { + it('takes the lowest of the proven, DA and gas-limit ceilings, preferring DA then the gas limit on ties', () => { + expect(sustainable(400, 82, 200)).toEqual({ mgas: 82, cap: 'DA' }); + expect(sustainable(400, 232, 200)).toEqual({ mgas: 200, cap: 'gas limit' }); + expect(sustainable(40, 82, 200)).toEqual({ mgas: 40, cap: 'execution' }); + expect(sustainable(200, 200, 200)).toEqual({ mgas: 200, cap: 'DA' }); + expect(sustainable(300, 400, 300)).toEqual({ mgas: 300, cap: 'gas limit' }); + expect(sustainable(300, 400, null)).toEqual({ mgas: 300, cap: 'execution' }); + }); + + it('converts proven and sustainable rates with each proven run’s gas per successful tx, and today’s mix with mainnet gas per tx', () => { + swr.result = { data: bench(400, true, { current: { mix: { mgas: 400, atTop: true, run: run(400) }, swap: null, payment: { mgas: 200, atTop: true, run: run(200, 'payment') } } }) }; + const { head } = render(); + // 195 Mgas/s ÷ 2,100 tx/s = 92,857 gas per tx; DA payments ~57.3 Mgas/s. + expect(tile(head, 'Payments (B20)')).toMatch(/≥ 2\.2k\/ssustainable today 617\/s.*capped by DA$/); + expect(tile(head, 'Swaps (Uniswap v3)')).toContain('pendingsustainable today pending'); + // 12.5 Mgas/s ÷ 64.2 tx/s = ~195k gas per tx on mainnet; DA mix ~190 Mgas/s. + // Tiles carry no multiples; the header owns the one multiple. + expect(tile(head, 'traffic mix')).toMatch(/≥ 2\.1k\/ssustainable today 974\/sHighest.*capped by DA/); + expect(head.split('rounded-lg border').slice(1).join('')).not.toContain('×'); + expect(tile(head, 'traffic mix')).toContain("At today's mainnet average of ~195k gas per transaction (12.5 Mgas/s ÷ 64 tx/s), not the bench mix."); + }); + + it('caps swaps at the gas limit once their DA is measured above it, with successful tx/s and the reverted note', () => { + const swap = { ...run(400, 'swap'), reverted: 0.155 }; + swr.result = { data: bench(400, true, { current: { mix: null, payment: null, swap: { mgas: 400, atTop: true, run: swap } }, da: { mix: null, payment: null, swap: run(400, 'swap', daResult(2.6158e7, 22.4, false)) } }) }; + expect(tile(render().head, 'Swaps (Uniswap v3)')).toMatch(/≥ 3.6k\/ssustainable today.*capped by gas limit16% reverted/); + expect(tile(render().head, 'Swaps (Uniswap v3)')).toMatch(/sustainable today 1,797\/s.*capped by gas limit/); + expect(render().head).toContain('text-bds-orange-60">sustainable today'); + }); + + it('caps by execution below every other ceiling, without ≥ below the top rate', () => { + swr.result = { data: bench(40, false, { current: { mix: null, swap: null, payment: { mgas: 40, atTop: false, run: run(40, 'payment') } } }) }; + expect(tile(render().head, 'Payments (B20)')).toMatch(/Payments \(B20\)480\/ssustainable today 480\/s.*capped by execution$/); + }); + + it('keeps the mix tile in gas without the snapshot’s transactions per second', () => { + swr.result = { data: bench(400, true) }; + const snapshot = fixture(); + delete snapshot.usage; + const mix = tile(render({ benchUrl: BENCH }, snapshot).head, 'traffic mix'); + expect(mix).toContain('—sustainable today —'); + expect(mix).toContain('stays in gas: today 12.5 Mgas/s'); + }); +}); + +describe('headroom span', () => { + it('divides proven capacity by today’s median, marking a floor with ≥', () => { + expect(headroomLabel({ value: 400, atLeast: true, help: '' }, 18.67)).toBe('≥ 21×'); + expect(headroomLabel({ value: 80, atLeast: false, help: '' }, 18.67)).toBe('4.3×'); + }); + + it('places today on a 0–proven track and centers the multiple over the gap, clamping labels inside', () => { + swr.result = { data: bench(400, true) }; + const { head } = render(); + // The server render assumes a 240 px column: today 12.5 of 400 sits at 7.5 px, so its label pins left. + expect(head).toMatch(/data-span="today"[^>]*left:7.5px/); + expect(head).toMatch(/data-span="gap"[^>]*left:7.5px/); + expect(head).toMatch(/data-span="headroom"[^>]*left:123.75px;transform:translateX\(-50%\)/); + expect(head).toMatch(/data-span-label="today"[^>]*style="left:0"/); + expect(head).not.toContain('data-span="limit"'); + expect(place(120, 40, 240)).toEqual({ side: 'center', x: 120 }); + expect(place(3, 40, 240)).toEqual({ side: 'left', x: 0 }); + expect(place(238, 40, 240)).toEqual({ side: 'right', x: 240 }); + }); + + it('hides the span without today’s median or a proven rate', () => { + swr.result = { data: bench(400, true) }; + const snapshot = fixture(); + snapshot.ladder.observed.p50 = { ...snapshot.ladder.observed.p50, value: null, tag: 'not-measured' }; + expect(render({ benchUrl: BENCH }, snapshot).head).not.toContain('data-span'); + swr.result = { data: { current: none, da: none, history: [], latestAt: null, unknown: [] } }; + expect(render().head).not.toContain('data-span'); + }); +}); + +describe('subsystems by workload', () => { + const cards = (html: string) => html.split(' ` cards(html).map((c) => text(c.split('')[0])); + const card = (html: string, title: string) => cards(html).find((c) => text(c.split('')[0]).startsWith(title))!; + const withStorage = () => { + const data = bench(400, true); + data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } }; + return data; + }; + + it('orders cards by their category’s place in What breaks first, unranked ones last in the default order', () => { + swr.result = { data: bench(400, true) }; + // DA ~57 first; the merged core row ties proof node, validators and sequencer; no storage leaves both disks unranked. + expect(titles(render().subsystems).map((t) => t.split(' · ')[0])).toEqual(['DA', 'Proof node', 'Validators', 'Sequencer', 'Disk', 'Disk']); + swr.result = { data: withStorage() }; + expect(titles(render().subsystems)).toEqual([ + 'DA · not measured yet', + 'Disk · archive · 1-year runway', + 'Proof node · within budget (lag ≤ 10, ≤ 1 s)', + 'Validators · all keep up', + 'Sequencer · 400 delivered', + 'Disk · full node · 2-year runway', + ]); + }); + + it('stacks one card per row below 760 px and labels columns with full names', () => { + swr.result = { data: bench(400, true) }; + const html = render().subsystems; + expect(html).toContain('grid grid-cols-1 gap-3 min-[760px]:grid-cols-2'); + expect(html).toContain('Pay'); + expect(html).toContain('title="Mainnet mix (bench run)"'); + }); + + it('rescales DA blobs to the load rate and names workloads whose ceiling is under the gas limit', () => { + // Payments: 20 blobs over a 160 Mgas/s window at a 200 Mgas/s load → 25, ceiling ~76. Swaps: ~232, above the 200 gas limit. + swr.result = { data: bench(400, true, { da: { mix: null, payment: run(200, 'payment', daResult(8e7, 20, false)), swap: run(400, 'swap', daResult(2.6158e7, 22.4, false)) } }) }; + const da = card(render().subsystems, 'DA'); + expect(text(da.split('')[0])).toBe('DA · binding for payments'); + expect(da.split('')[0]).toContain('text-bds-red-60'); + expect(rows(da)).toEqual(['Blobs per L1 slot at each run’s rate 2556— (not in this run)', 'Ceiling (Mgas/s) 76232— (not in this run)']); + swr.result = { data: bench(400, true, { da: { mix: null, payment: null, swap: run(400, 'swap', daResult(2.6158e7, 22.4, false)) } }) }; + expect(text(card(render().subsystems, 'DA').split('')[0])).toBe('DA · below target at the 200 Mgas/s gas limit'); + const noLimit = fixture(); + noLimit.ladder.configuredLimit = { ...noLimit.ladder.configuredLimit, value: null }; + expect(text(card(render({ benchUrl: BENCH }, noLimit).subsystems, 'DA').split('')[0])).toBe('DA · no gas limit to compare against'); + }); + + it('projects disk per workload run, saying which runs lack a storage measurement', () => { + const data = withStorage(); + data.current.payment = { mgas: 200, atTop: true, run: run(200, 'payment') }; + swr.result = { data }; + const html = render().subsystems; + // (0.0086 bench + 0.0008 mainnet state) B/gas × 400 Mgas/s × 1 year; (0.0086 × 31 d + 0.0045 × 15 d) × 400 Mgas/s. + expect(rows(card(html, 'Disk · archive'))).toEqual([ + 'Growth at 400 — (needs a run with storage measurement)— (no proven run for this workload)≥ 118.7 TB/yr', + 'Ceiling (1-year runway) — (needs a run with storage measurement)— (no proven run for this workload)≤ 138', + ]); + expect(rows(card(html, 'Disk · full node'))[0]).toBe('Size at 400 — (needs a run with storage measurement)— (no proven run for this workload)11.5 TB'); + expect(html).toContain('title="needs a run with storage measurement"'); + }); + + it('flags proof-node budgets and validators that fall behind', () => { + const data = bench(400, true); + const slow = run(400, 'mix', null, 11); + slow.proofNode!.proofs!.methods.debug_executePayload.latency.p99Ms = 1200; + slow.replay = { ...slow.replay!, blocksPerSecond: 4 }; + data.current.mix = { mgas: 400, atTop: true, run: slow }; + data.current.swap = { mgas: 400, atTop: true, run: run(400, 'swap') }; + swr.result = { data }; + const html = render().subsystems; + expect(text(card(html, 'Proof node').split('')[0])).toBe('Proof node · lag over 10 for mix; executePayload over 1 s for mix'); + expect(rows(card(html, 'Proof node'))).toEqual([ + 'Lag p99 (max) — (no proven run for this workload)4 (20)11 (20)', + 'executePayload p99 — (no proven run for this workload)733 ms1200 ms', + 'Witness p50 — (no proven run for this workload)— (not in this run)— (not in this run)', + ]); + expect(text(card(html, 'Validators').split('')[0])).toBe('Validators · mix does not keep up'); + expect(rows(card(html, 'Validators'))[0]).toBe('Replay speed — (no proven run for this workload)7.3×0.8×'); + expect(card(html, 'Validators').split('')[0]).toContain('text-bds-red-60'); + }); + + it('shows delivered rate, successful tx/s with reverts over 1%, and block latency', () => { + const data = bench(400, true); + data.current.swap = { mgas: 400, atTop: true, run: { ...run(400, 'swap'), reverted: 0.155, blockLatency: { p50Ms: 205.4, p99Ms: 306.3 } } }; + data.current.payment = { mgas: 200, atTop: true, run: { ...run(200, 'payment'), reverted: 0.005 } }; + swr.result = { data }; + const seq = card(render().subsystems, 'Sequencer'); + expect(text(seq.split('')[0])).toBe('Sequencer · 200–400 delivered'); + // 4,200 submitted tx/s × (1 − 15.5% reverted) = 3,549. + expect(rows(seq)).toEqual([ + 'Delivered (Mgas/s) 195.02,090 tx/s395.03,549 tx/s16% reverted395.04,200 tx/s', + 'Block latency p50 / p99 — (not in this run)205 / 306 ms— (not in this run)', + ]); + }); +}); + +describe('ladder', () => { + it('shades the used band in two tones and labels today, p99, fee target, gas limit and proven', () => { + swr.result = { data: bench(400, true) }; + const snapshot = fixture(); + snapshot.ladder.observed.p99 = { ...snapshot.ladder.observed.p99, value: 42.24, tag: 'measured' }; + const { ladder } = render({ benchUrl: BENCH }, snapshot); + expect(ladder).toMatch(/data-band="p50" width="3.125%"/); + expect(ladder).toMatch(/data-band="p99" width="10.56%"/); + const label = (id: string) => text(ladder.split(`data-label="${id}"`)[1]?.split('data-label=')[0] ?? '').replace(/^[^>]*>/, ''); + expect(label('today')).toMatch(/^12\.5.*today/); + expect(label('p99')).toMatch(/^42\.2.*p99/); + expect(label('fee')).toMatch(/^40.*fee target/); + expect(label('gas')).toMatch(/^200.*gas limit/); + expect(label('proven')).toMatch(/^≥ 400.*proven/); + expect(ladder).toMatch(/data-tick="gas" class="[^"]*border-dashed border-bds-orange-60/); + expect(text(ladder)).toContain('up to medianmedian to p99allowed by policyproven, not yet allowed'); + expect(text(ladder)).not.toMatch(/42\.24|Usage data/); + }); + + it('ticks every ranked breaking point in its colour at its value, and none for unranked rows', () => { + swr.result = { data: bench(400, true) }; + const { ladder } = render(); + // On the 960 px server-render width: DA payments ~57, swaps ~115, mix ~190 Mgas/s of 400. + expect(ladder).toMatch(/data-tick="da-payment" class="[^"]*border-bds-red-60" style="left:137\.43\d*px/); + expect(ladder).toMatch(/data-tick="da-swap" class="[^"]*border-bds-red-60" style="left:275\.94\d*px/); + expect(ladder).toMatch(/data-tick="da-mix" class="[^"]*border-bds-red-60" style="left:455\.09\d*px/); + // Without a storage run both disk rows are unranked, so neither gets a tick. + expect(ladder).not.toMatch(/data-(tick|label)="disk-/); + }); + + it('pins a breaking point beyond the scale to the right edge without a tick', () => { + const data = bench(400, true); + data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } }; + swr.result = { data }; + const { ladder } = render(); + expect(ladder).toMatch(/data-tick="disk-archive" class="[^"]*border-bds-purple-60"/); + expect(ladder).not.toContain('data-tick="disk-full"'); + expect(text(ladder.split('data-label="disk-full"')[1].split('data-label=')[0])).toMatch(/≤ 553 →.*full-node disk/); + }); + + it('keeps labels apart in a crowded set, dropping names to a legend before stacking tiers', () => { + const point = (id: string, value: number): Point => ({ id, value, valueText: String(value), name: `limit ${id}`, tone: 'da', help: '' }); + const crowded = [10, 12, 14, 16, 18, 20, 22, 24, 26, 600].map((v, i) => point(`p${i}`, v)); + for (const width of [960, 600, 320]) { + const placed = layoutPoints(crowded, 400, width); + const top = (p: PlacedPoint) => (p.side === 'above' ? -1 : 1) * (p.tier + 1); + for (const a of placed) { + expect(a.left).toBeGreaterThanOrEqual(0); + expect(a.left + a.width).toBeLessThanOrEqual(width + 0.001); + for (const b of placed) { + if (a !== b && top(a) === top(b)) expect(a.left + a.width <= b.left || b.left + b.width <= a.left).toBe(true); + } + } + expect(placed.at(-1)).toMatchObject({ id: 'p9', offScale: true, x: width }); + } + // Long names on a 320 px track: the third label fits beside the first only without its name. + const long = (id: string, value: number): Point => ({ ...point(id, value), name: 'a breaking point with a long name' }); + expect(layoutPoints([long('a', 100), long('b', 180), long('c', 270)], 320, 320).map((p) => [p.side, p.tier, p.compact])).toEqual([ + ['above', 0, false], + ['below', 0, false], + ['above', 0, true], + ]); + }); + + it('shades only to p50 without a p99, and draws no used band without either', () => { + const { ladder } = render({}); + expect(ladder).toMatch(/data-band="p50" width="6.25%"/); + expect(ladder).not.toContain('data-band="p99"'); + const snapshot = fixture(); + snapshot.ladder.observed.p50 = { ...snapshot.ladder.observed.p50, value: null, tag: 'not-measured' }; + expect(render({}, snapshot).ladder).not.toContain('data-band'); + }); +}); + +describe('what breaks first', () => { + it('ranks DA estimates first without bench data, with units, and leaves unproven limits unranked', () => { + expect(rows(render({}).ranked)).toEqual([ + '1DA · Payments (B20)next~57 Mgas/s≈ 1,517 tx/sEstimatedEstimated: no bench DA run yet, so this uses the formula. Target is 14, shared by all rollups.', + '2DA · Swaps~115 Mgas/s≈ 799 tx/sEstimatedEstimated: no bench DA run yet, so this uses the formula. Target is 14.', + '3DA · Mix (bench)~190 Mgas/s≈ 974 tx/sEstimatedEstimated: no bench DA run yet, so this uses the formula. Target is 14.', + '—Execution (sequencer)—no proven run', + '—Validators—pending', + '—Proof node—pending', + '—Disk · full node—needs a bench storage-growth run', + '—Disk · archive node—needs a bench storage-growth run', + '—TEE proving, RPC under sync—Not measured yet.', + ]); + }); + + it('merges execution, validators and proof node when they share the proven ceiling, with the run in help', () => { + swr.result = { data: bench(400, true) }; + const { ranked } = render(); + expect(rows(ranked)[3]).toBe('4Execution, validators, proof node≥ 400 Mgas/s≥ 2,054 tx/sMeasured in the bench: all passed at 400 Mgas/s, the highest rate tested.'); + expect(ranked).toContain('Run synthetic-400. Validator replay: 36.4 blocks/s, 7.3× the 5 needed. Proof node: lag p99 4 blocks, executePayload p99 733 ms.'); + expect(ranked.match(/>next { + const over = bench(400, true); + over.current.mix = { mgas: 400, atTop: true, run: run(400, 'mix', null, 11) }; + swr.result = { data: over }; + expect(rows(render().ranked).slice(3, 6)).toEqual([ + '4Execution (sequencer)≥ 400 Mgas/s≥ 2,054 tx/stop rate tested', + '5Validators≥ 400 Mgas/s≥ 2,054 tx/s7.3× replay headroom', + '—Proof node—over budget at 400 · lag p99 11 blocks', + ]); + }); + + it('ranks the mix by the bench mix run’s DA everywhere on the page, with the bench evidence', () => { + // 4.3e7 frame bytes over 4e10 gas put the 14-blob ceiling at ~141 Mgas/s; 16 blobs over a 160 Mgas/s window at 400 → 40. + swr.result = { data: bench(400, true, { da: { mix: run(400, 'mix', daResult(4.3e7, 16, false)), payment: null, swap: null } }) }; + const { head, ladder, ranked, subsystems } = render(); + // The mix converts at today's mainnet gas per transaction, so it equals the traffic tile's sustainable today. + expect(rows(ranked)[2]).toMatch(/^3DA · Mix \(bench\)141 Mgas\/s≈ 725 tx\/sMeasured in the bench: at 400 Mgas\/s, needs 40 blobs per L1 slot\. Target is 14\.$/); + // 12.5 Mgas/s ÷ 64.2 tx/s today ≈ 195k gas per transaction; the bench mix run used 40k. + expect(ranked).toContain('tx/s at today's mainnet average of ~195k gas per transaction (the bench mix is lighter: ~40k).'); + expect(ranked).not.toContain('>Measured<'); + expect(text(ladder)).toMatch(/141.*DA · mix \(bench\)/); + expect(tile(head, 'traffic mix')).toContain('capped by DA'); + expect(head).toContain('Its DA cap comes from the bench mix run, which is lighter per transaction and compresses worse than mainnet, so the cap is conservative.'); + expect(rows(subsystems.split(' { + const data = bench(400, true, { da: { mix: run(400, 'mix', daResult(4.3e7, 16, false)), payment: run(200, 'payment', daResult(8e7, 20, false)), swap: null } }); + data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } }; + swr.result = { data }; + const { head, ranked } = render(); + const row = (label: string) => rows(ranked).find((r) => r.includes(label))!; + // Payments: its DA run's 40k gas per successful transaction. Every other row: 12.5 Mgas/s ÷ 64.2 tx/s ≈ 195k, a bound kept on both numbers. + expect(row('Payments')).toContain('76 Mgas/s≈ 1,896 tx/s'); + expect(row('Mix (bench)')).toContain('141 Mgas/s≈ 725 tx/s'); + expect(row('archive node')).toContain('≤ 138 Mgas/s≤ 710 tx/s'); + expect(row('Execution, validators')).toContain('≥ 400 Mgas/s≥ 2,054 tx/s'); + expect(row('full node')).toContain('≤ 553 Mgas/s≤ 2,841 tx/s'); + expect(row('TEE proving')).not.toContain('tx/s'); + // Same function and rounding as the traffic tile's sustainable today. + expect(tile(head, 'traffic mix')).toContain(`sustainable today ${/≈ ([\d,]+) tx\/s/.exec(row('Mix (bench)'))![1]}/s`); + expect(ranked).toContain('tx/s for payments and swaps uses that workload's transactions; every other row uses today's mainnet average of ~195k gas per transaction.'); + }); + + it('keeps shared rows in Mgas/s without today’s transactions per second', () => { + const data = bench(400, true, { da: { mix: run(400, 'mix', daResult(4.3e7, 16, false)), payment: run(200, 'payment', daResult(8e7, 20, false)), swap: null } }); + data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } }; + swr.result = { data }; + const snapshot = fixture(); + delete snapshot.usage; + const { ranked } = render({ benchUrl: BENCH }, snapshot); + const row = (label: string) => rows(ranked).find((r) => r.includes(label))!; + expect(row('Payments')).toContain('76 Mgas/s≈ 1,896 tx/s'); + for (const label of ['Mix (bench)', 'archive node', 'Execution, validators', 'full node']) expect(row(label)).not.toContain('tx/s'); + expect(ranked).toContain('the snapshot has no transactions per second, so every other row stays in Mgas/s.'); + }); + + it('says the next rate did not pass when the core limit is lowest below the top rate', () => { + swr.result = { data: bench(40, false) }; + expect(rows(render().ranked)[0]).toBe('1Execution, validators, proof nodenext40 Mgas/s≈ 205 tx/sMeasured in the bench: all passed at 40 Mgas/s; the next rate tested did not pass.'); + }); + + it('rescales a bench DA run’s posting-window blobs to its load rate, with the details in help', () => { + // 20 blobs per slot over a window at 160 Mgas/s, rescaled to the 200 Mgas/s load: 25. + swr.result = { data: bench(400, true, { da: { mix: null, payment: run(200, 'payment', daResult(8e7, 20, false)), swap: null } }) }; + const { ranked } = render(); + expect(rows(ranked)[0]).toBe('1DA · Payments (B20)next76 Mgas/s≈ 1,896 tx/sMeasured in the bench: at 200 Mgas/s, needs 25 blobs per L1 slot. Target is 14, shared by all rollups.'); + expect(ranked).toContain('Run synthetic-200: 0.00200 compressed bytes per gas (compression 4.5×). Over the batch posting window it averaged 20.0 blobs per L1 slot at 160.0 Mgas/s; scaling that window to the 200 Mgas/s load gives 25.'); + expect(ranked).toContain('14 × 130,044 bytes per blob ÷ 12 s per slot ÷ bytes per gas.'); + }); + + it('notes swaps reverts above 1% only', () => { + const swapDa = (reverted: number) => ({ ...run(400, 'swap', daResult(2.6158e7, 22.4, false)), reverted }); + swr.result = { data: bench(400, true, { da: { mix: null, payment: null, swap: swapDa(0.155) } }) }; + expect(rows(render().ranked).find((r) => r.includes('DA · Swaps'))).toMatch(/needs 56 blobs per L1 slot\. Target is 14\. 16% of swaps reverted\.$/); + swr.result = { data: bench(400, true, { da: { mix: null, payment: null, swap: swapDa(0.005) } }) }; + expect(rows(render().ranked).find((r) => r.includes('DA · Swaps'))).toMatch(/Target is 14\.$/); + }); + + it('ranks a measured swaps DA ceiling by its value, past the gas limit', () => { + // 4e10 gas and 2.6158e7 frame bytes put the 14-blob ceiling at ~232 Mgas/s. + swr.result = { data: bench(400, true, { da: { mix: null, payment: null, swap: run(400, 'swap', daResult(2.6158e7, 22.4, false)) } }) }; + const ranked = render().ranked; + expect(rows(ranked).slice(0, 4).map((r) => r.slice(0, 22))).toEqual(['1DA · Payments (B20)ne', '2DA · Mix (bench)~190 ', '3DA · Swaps232 Mgas/s≈', '4Execution, validators']); + const swapsRow = ranked.split(' r.includes('>DA · Swaps'))!; + expect(swapsRow).toMatch(/style="width:58\.\d+%"/); + expect(swapsRow).toMatch(/border-dashed[^"]*" style="left:50%"/); + }); + + it('lays out the proven run as a definition list, with the change only when there is one', () => { + swr.result = { data: bench(400, true) }; + const block = text(render().ranked.split(']*>Runsynthetic-400Rate400 → 395\.0 Mgas\/sCommitbbbbbbbDate2026-01-06Change↑ from 40 Mgas\/s on 2026-01-06 · compareSteps/); + expect(render().ranked).toContain('href="https://github.com/base/base/compare/aaaa...bbbb"'); + swr.result = { data: bench(400, true, { history: [] }) }; + expect(text(render().ranked.split(' { + const withStorage = (more: Partial = {}) => { + const data = bench(400, true, more); + data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } }; + return data; + }; + const disk = (ranked: string, label: string) => rows(ranked).find((r) => r.includes(label))!; + + it('ranks both disk rows by their upper-bound ceilings, with plain-language evidence', () => { + swr.result = { data: withStorage() }; + const { ranked } = render(); + expect(rows(ranked).map((r) => r.slice(0, 26))).toEqual([ + '1DA · Payments (B20)next~5', + '2DA · Swaps~115 Mgas/s≈ 79', + '3Disk · archive node≤ 138 ', + '4DA · Mix (bench)~190 Mgas', + '5Execution, validators, pr', + '6Disk · full node≤ 553 Mga', + '—TEE proving, RPC under sy', + ]); + // (45 TB − 4 TB) ÷ (1e6 × (0.0086 bench + 0.0008 mainnet state) B/gas × 1 year); (45 TB − 1.1 TB state) ÷ (1e6 × (history 31 d + proofs 15 d + 2 years of 0.0008 B/gas state)). + expect(disk(ranked, 'archive node')).toBe('3Disk · archive node≤ 138 Mgas/s≤ 710 tx/sProjected from bench growth: one year fills the 45.0 TB disk (4.0 TB used today). Upper bound.'); + expect(disk(ranked, 'full node')).toBe('6Disk · full node≤ 553 Mgas/s≤ 2,841 tx/sProjected from bench growth: 31 days of history, 15 days of proofs and 2 years of state growth fit in 45.0 TB. Upper bound.'); + }); + + it('pluralizes the runway', () => { + swr.result = { data: withStorage() }; + const snapshot = fixture(); + snapshot.policy!.runwayYears = { fullNode: 1, archive: 2 }; + const { ranked } = render({ benchUrl: BENCH }, snapshot); + expect(disk(ranked, 'archive node')).toContain('Projected from bench growth: 2 years fill the 45.0 TB disk'); + expect(disk(ranked, 'full node')).toContain('and one year of state growth fit in 45.0 TB'); + }); + + it('lists every input and why the ceilings are upper bounds in help', () => { + swr.result = { data: withStorage() }; + const help = render().ranked; + expect(help).toContain("Inputs: 45.0 TB disk budget (a bench config assumption); today's archive 4.0 TB from Synthetic archive snapshot on 2026-01-01 (invented size); 0.00860 bytes per gas from the mainnet mix run plus 0.0008 bytes per gas of mainnet state growth from Synthetic dashboard, since the bench run writes almost no new state; runway policy: full node 2 years, archive one year (Invented runway policy.)."); + expect(help).toContain("Inputs: 45.0 TB disk budget (a bench config assumption); today's state 1.1 TB, measured at the mainnet mix run's snapshot; 0.00860 bytes per gas of history and 0.00450 of proofs from the mainnet mix run; 0.0008 bytes per gas of production state growth from Synthetic dashboard;"); + expect(help).toContain('Upper bound: bench bytes per gas is a lower bound, because the sequencer prunes history.'); + expect(help).toContain("may write more bytes per gas than mainnet traffic does (Base's whole archive is 4.0 TB)"); + }); + + it('counts only the bench run’s state in the archive ceiling without production state growth', () => { + swr.result = { data: withStorage() }; + const snapshot = fixture(); + delete snapshot.production!.stateBytesPerGas; + // (45 TB − 4 TB) ÷ (1e6 × 0.0086 B/gas × 1 year). + const { ranked } = render({ benchUrl: BENCH }, snapshot); + expect(disk(ranked, 'archive node')).toMatch(/≤ 151 Mgas\/s≤ 776 tx\/sProjected from bench growth/); + expect(ranked).toContain('0.00860 bytes per gas from the mainnet mix run (bench state only)'); + }); + + it('names the missing input and leaves the row unranked', () => { + swr.result = { data: withStorage() }; + const noPolicy = fixture(); + delete noPolicy.policy; + expect(disk(render({ benchUrl: BENCH }, noPolicy).ranked, 'archive node')).toBe('—Disk · archive node—needs a runway policy'); + expect(disk(render({ benchUrl: BENCH }, noPolicy).ranked, 'full node')).toBe('—Disk · full node—needs a runway policy'); + const noProduction = fixture(); + delete noProduction.production; + expect(disk(render({ benchUrl: BENCH }, noProduction).ranked, 'archive node')).toBe('—Disk · archive node—needs the production archive size'); + expect(disk(render({ benchUrl: BENCH }, noProduction).ranked, 'full node')).toBe('—Disk · full node—needs production state growth'); + const data = withStorage(); + data.current.mix!.run.storage = { ...storage(), bytesPerGas: { history: 0.0086, proofs: null, archive: 0.0086 } }; + swr.result = { data }; + expect(disk(render().ranked, 'full node')).toBe('—Disk · full node—needs proof storage growth'); + }); + + it('ranks an archive already over budget first', () => { + swr.result = { data: withStorage() }; + const snapshot = fixture(); + snapshot.production!.archiveBytes!.value = 50e12; + const { ranked } = render({ benchUrl: BENCH }, snapshot); + expect(rows(ranked)[0]).toBe('1Disk · archive nodenextover budgetProjected from bench growth: today\'s archive (50.0 TB) already fills the 45.0 TB disk.'); + }); + + it('takes storage growth from the run with the most gas per successful transaction, saying why', () => { + const data = withStorage(); + // Mix: 395 Mgas/s ÷ 4,200 tx/s ≈ 94k gas per tx. Swaps: 395 Mgas/s ÷ 2,000 tx/s ≈ 198k, the heaviest. + data.current.swap = { mgas: 400, atTop: true, run: { ...run(400, 'swap'), tps: 2000, storage: { ...storage(), bytesPerGas: { history: 0.0043, proofs: 0.0045, archive: 0.0043 } } } }; + swr.result = { data }; + const { ranked } = render(); + // (45 TB − 4 TB) ÷ (1e6 × (0.0043 swaps + 0.0008 mainnet state) B/gas × 1 year). + expect(disk(ranked, 'archive node')).toMatch(/^\d+Disk · archive node≤ 255 Mgas\/s/); + expect(ranked).toContain('0.00430 bytes per gas from the swaps run'); + expect(ranked).not.toContain('from the mainnet mix run'); + expect(ranked.match(/Uses the workload with the heaviest transactions, because bench workloads with lighter transactions than mainnet overstate history bytes per gas\. A production measurement will replace this\./g)).toHaveLength(2); + }); + + it('falls back to the only run with storage growth, even a light one', () => { + const data = bench(400, true, { da: { mix: null, payment: { ...run(200, 'payment'), storage: storage() }, swap: null } }); + swr.result = { data }; + expect(render().ranked).toContain('0.00860 bytes per gas from the payments run'); + }); +}); + +describe('guardrails summary and work', () => { + // The summary is off the first screen for now; the component stays for when it returns. + const guardrails = (benchUrl?: string) => renderToStaticMarkup(); + + it('compresses guardrails to breached, met and not-measured counts with a link', () => { + const summary = guardrails(); + expect(text(summary)).toContain('Breached 1 — RPC availability 99.5 %Met 2Not measured 3'); + expect(text(summary)).toContain('RPC p99 latencyPropagation p99State growth'); + expect(summary).toContain('href="/scaling/guardrails"'); + swr.result = { data: bench(400, true) }; + expect(text(guardrails(BENCH))).toContain('Met 5'); + }); + + it('logs work in progress, then planned, then done, hiding other statuses', () => { + const work = render({}).work; + expect(text(work.split('')[0])).toContain('StatusWorkImpact categoryEffectLinks'); + // The legacy "Research-derived proposal" entry is not shown; the Linear project and evidence of an older entry become links. + expect(rows(work)).toEqual([ + 'In ProgressSynthetic unified binaryExecutionabout 20% faster block processing (synthetic demo)Linear · Synthetic run', + 'PlannedSynthetic blob batching changeDAnot measured yetSynthetic doc', + ]); + expect(work).toContain('Linear'); + expect(text(work)).not.toMatch(/legacy proposal|Expected effects are not summed/); + }); + + it('falls back to an older entry’s expected effect and area, and to an empty state', () => { + const snapshot = fixture(); + const [first, second] = snapshot.work; + snapshot.work = [ + { ...second, status: 'done', category: undefined, links: [{ label: 'Doc', url: 'https://example.com/doc' }, { label: 'Linear', url: 'https://example.com/linear' }] }, + { ...first, effect: undefined, expected: 'about 10% faster' }, + ]; + expect(rows(render({}, snapshot).work)).toEqual([ + 'In ProgressSynthetic unified binaryExecutionabout 10% fasterLinear · Synthetic run', + 'CompletedSynthetic blob batching changeDAnot measured yetDoc · Linear', + ]); + snapshot.work = [{ ...first, status: 'Proposed' }]; + expect(text(render({}, snapshot).work)).toContain('No work in progress or planned for this snapshot.'); + }); +}); diff --git a/app/scaling/da/workloads.test.ts b/app/scaling/da/workloads.test.ts index a6a3c6b..3d83a2a 100644 --- a/app/scaling/da/workloads.test.ts +++ b/app/scaling/da/workloads.test.ts @@ -1,13 +1,14 @@ import { expect, it } from 'vitest'; import type { Ceiling } from '../snapshot.mjs'; -import { BLOB_MAX, BLOB_TARGET, daCapacity, withDaWorkloads, type DaWorkload } from './workloads'; +import type { Da, Run } from '../proven/capacity'; +import { BLOB_MAX, BLOB_TARGET, daCapacity, daWorkloads, DA_WORKLOADS, withDaWorkloads, type DaWorkload } from './workloads'; const ceiling = (area: Ceiling['area'], value: number): Ceiling => ({ area, value, unit: 'Mgas/s', tag: 'modeled', formula: 'snapshot', inputs: [], help: 'snapshot', }); const workload = (label: string, bytesPerTx: number, gasPerTx: number, tag: DaWorkload['tag']): DaWorkload => ({ - label, benchWorkload: label, bytesPerTx, gasPerTx, tag, asOf: null, + label, payload: 'mix', benchWorkload: label, bytesPerTx, gasPerTx, tag, asOf: null, }); it('turns bytes per transaction into tx/s and gas/s at a blob count', () => { @@ -32,3 +33,43 @@ it('is modeled once every workload is measured', () => { const [da] = withDaWorkloads([ceiling('da', 820)], [workload('mix', 155, 193_000, 'measured')]); expect(da.tag).toBe('modeled'); }); + +const run = (workload: Run['workload'], da: Run['da']): Run => ({ + id: `synthetic-${workload}`, outputDir: `synthetic-${workload}`, workload, mgas: 200, gps: 2e8, tps: 4000, reverted: 0, storage: null, blockLatency: null, passed: true, + commit: 'c0ffee', createdAt: Date.parse('2026-01-05T00:00:00Z'), steps: [], replay: null, proofNode: null, da, +}); +const da = (frame_bytes: number, gas_used: number, transactions: number): Da => ({ + result: { seconds: 600, gas_used, transactions, transaction_bytes: 0, frame_bytes, gas_per_second: 0, bytes_per_gas: frame_bytes / gas_used, compression_ratio: 0, blobs_per_l1_slot: 15.5, blob_fill: 1, blob_target_gas_per_second: 0 }, + target: 14, + withinTarget: false, +}); + +it('measures bytes/tx and gas/tx from a bench DA run, keeping the mainnet mix and estimates without one', () => { + const payment = run('payment', da(8e7, 4e10, 1e6)); + const [payments, swaps, mix] = daWorkloads({ payment, swap: run('swap', null), mix: run('mix', da(1, 1, 1)) }); + expect(payments).toMatchObject({ + bytesPerTx: 80, gasPerTx: 40_000, tag: 'measured', asOf: '2026-01-05T00:00:00.000Z', + source: { label: 'bench 200 Mgas/s run', url: '/scaling/runs/bench/synthetic-payment' }, bench: { run: payment }, + }); + // bytes/tx ÷ gas/tx is the run's frame bytes per gas, so the 14-blob formula matches the run's own. + expect(daCapacity(payments, BLOB_TARGET).mgasPerSecond).toBeCloseTo((14 * 130_044) / 12 / payment.da!.result.bytes_per_gas / 1e6); + expect(swaps).toBe(DA_WORKLOADS[1]); + expect(mix).toMatchObject({ bytesPerTx: DA_WORKLOADS[2].bytesPerTx, tag: 'measured', bench: { da: { withinTarget: false } } }); + expect(daWorkloads(undefined)).toEqual(DA_WORKLOADS); +}); + +it('counts only successful transactions per second, keeping gas per second', () => { + const swap = { ...run('swap', da(1.58e8, 2.4e11, 2e6)), reverted: 0.155 }; + const [, swaps] = daWorkloads({ payment: null, swap, mix: null }); + // 2,000,000 × 0.845 = 1,690,000 successful transactions carry all the bytes and gas. + expect(swaps.gasPerTx).toBeCloseTo(2.4e11 / 1.69e6); + expect(swaps.bytesPerTx).toBeCloseTo(1.58e8 / 1.69e6); + const allTx = daWorkloads({ payment: null, swap: { ...swap, reverted: 0 }, mix: null })[1]; + expect(daCapacity(swaps, BLOB_TARGET).txPerSecond).toBeCloseTo(0.845 * daCapacity(allTx, BLOB_TARGET).txPerSecond); + expect(daCapacity(swaps, BLOB_TARGET).mgasPerSecond).toBeCloseTo(daCapacity(allTx, BLOB_TARGET).mgasPerSecond); +}); + +it('is modeled once payments and swaps are measured', () => { + const [da2] = withDaWorkloads([ceiling('da', 820)], daWorkloads({ payment: run('payment', da(8e7, 4e10, 1e6)), swap: run('swap', da(2e8, 1.4e11, 1e6)), mix: null })); + expect(da2.tag).toBe('modeled'); +}); diff --git a/app/scaling/da/workloads.ts b/app/scaling/da/workloads.ts index ba621c6..318c784 100644 --- a/app/scaling/da/workloads.ts +++ b/app/scaling/da/workloads.ts @@ -1,28 +1,34 @@ +import { runHref, type Da, type ProvenCapacity, type Run, type Workload } from '../proven/capacity'; import type { Ceiling, Link } from '../snapshot.mjs'; // Ethereum's blob target and max per L1 block since BPO2 (2026-01-07), and the data one blob carries. export const BLOB_TARGET = 14; export const BLOB_MAX = 21; -const BLOB_DATA_BYTES = 130_044; -const L1_SLOT_SECONDS = 12; +export const BLOB_DATA_BYTES = 130_044; +export const L1_SLOT_SECONDS = 12; export type DaWorkload = { label: string; + payload: Workload; benchWorkload: string; gasPerTx: number; bytesPerTx: number; tag: 'measured' | 'estimated'; asOf: string | null; source?: Link; + // The latest bench run's DA step, shown beside the row whether or not it set bytes/tx. + bench?: { run: Run; da: Da }; }; -// Bytes per transaction come from `base-bench da`; gas per transaction for bench workloads from the -// load-test calibration (etc/scripts/local/mainnet-mix.py in base/base). +// Bytes per transaction are compressed batch frame bytes per non-deposit transaction from +// `base-bench da`, the bytes that fill a blob; gas per transaction for bench workloads comes from +// the load-test calibration (etc/scripts/local/mainnet-mix.py in base/base). export const DA_WORKLOADS: DaWorkload[] = [ - { label: 'Payments (B20)', benchWorkload: 'b20-transfer', gasPerTx: 37_743, bytesPerTx: 100, tag: 'estimated', asOf: null }, - { label: 'Swaps', benchWorkload: 'uniswap-swap', gasPerTx: 143_987, bytesPerTx: 190, tag: 'estimated', asOf: null }, + { label: 'Payments (B20)', payload: 'payment', benchWorkload: 'b20-transfer', gasPerTx: 37_743, bytesPerTx: 100, tag: 'estimated', asOf: null }, + { label: 'Swaps', payload: 'swap', benchWorkload: 'uniswap-swap', gasPerTx: 143_987, bytesPerTx: 190, tag: 'estimated', asOf: null }, { label: 'Mainnet mix', + payload: 'mix', benchWorkload: 'mainnet blocks 53,091,727–53,095,326', gasPerTx: 193_349, bytesPerTx: 154.7, @@ -32,6 +38,30 @@ export const DA_WORKLOADS: DaWorkload[] = [ }, ]; +// A bench DA run replaces an estimate with frame_bytes and gas_used per successful transaction (reverted +// ones still use gas and blob space), so bytes/tx ÷ gas/tx is the run's bytes per gas and tx/s counts +// successful transactions only. The mainnet mix keeps its measurement from real +// mainnet blocks, which the synthetic bench mix only approximates. +export function daWorkloads(da: ProvenCapacity['da'] | undefined, workloads: DaWorkload[] = DA_WORKLOADS): DaWorkload[] { + return workloads.map((w) => { + const run = da?.[w.payload]; + if (!run?.da) return w; + const bench = { run, da: run.da }; + if (w.tag === 'measured') return { ...w, bench }; + const { frame_bytes, gas_used } = run.da.result; + const transactions = run.da.result.transactions * (1 - run.reverted); + return { + ...w, + gasPerTx: gas_used / transactions, + bytesPerTx: frame_bytes / transactions, + tag: 'measured', + asOf: new Date(run.createdAt).toISOString(), + source: { label: `bench ${run.mgas} Mgas/s run`, url: runHref(run) }, + bench, + }; + }); +} + export function daCapacity(w: DaWorkload, blobs: number): { txPerSecond: number; mgasPerSecond: number } { const txPerSecond = (blobs * BLOB_DATA_BYTES) / L1_SLOT_SECONDS / w.bytesPerTx; return { txPerSecond, mgasPerSecond: (txPerSecond * w.gasPerTx) / 1e6 }; diff --git a/app/scaling/fixtures/synthetic.v2.json b/app/scaling/fixtures/synthetic.v2.json index 4ac44f3..49dfca5 100644 --- a/app/scaling/fixtures/synthetic.v2.json +++ b/app/scaling/fixtures/synthetic.v2.json @@ -5,7 +5,8 @@ "context": "Synthetic fixture: every number is invented to exercise the schema. Not a measurement of any network.", "windows": { "day": { "label": "24 h", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z" }, - "week": { "label": "7 d", "start": "2025-12-26T00:00:00Z", "end": "2026-01-02T00:00:00Z" } + "week": { "label": "7 d", "start": "2025-12-26T00:00:00Z", "end": "2026-01-02T00:00:00Z" }, + "month": { "label": "the last 30 days", "start": "2025-12-03T00:00:00Z", "end": "2026-01-02T00:00:00Z" } }, "ladder": { "observed": { @@ -195,6 +196,21 @@ }, "gasPerSwap": null }, + "policy": { + "runwayYears": { "fullNode": 2, "archive": 1, "help": "Invented runway policy." } + }, + "production": { + "archiveBytes": { "value": 4000000000000, "asOf": "2026-01-01", "source": "Synthetic archive snapshot", "note": "invented size" }, + "stateBytesPerGas": { "value": 0.0008, "window": "week", "source": "Synthetic dashboard" } + }, + "usage": { + "transactionsPerSecond": { + "value": 64.2, + "window": "month", + "help": "Invented: synthetic transactions per second averaged over the window.", + "source": { "label": "Synthetic dashboard", "url": "https://example.com/synthetic/tps" } + } + }, "sections": [ { "id": "da", @@ -246,7 +262,9 @@ { "area": "execution", "title": "Synthetic unified binary", - "status": "In progress", + "status": "in-progress", + "category": "Execution", + "effect": "about 20% faster block processing (synthetic demo)", "owner": "Synthetic owner", "hypothesis": "Fewer process hops per block speed up block processing.", "change": "Run the consensus and execution clients in one process.", @@ -268,7 +286,8 @@ { "area": "da", "title": "Synthetic blob batching change", - "status": "Proposed", + "status": "planned", + "category": "DA", "owner": null, "hypothesis": "Denser batches post fewer bytes per gas.", "change": "Not yet specified.", @@ -277,6 +296,19 @@ "evidence": null, "linear": null, "links": [{ "label": "Synthetic doc", "url": "https://example.com/synthetic/doc" }] + }, + { + "area": "networking", + "title": "Synthetic legacy proposal", + "status": "Research-derived proposal", + "owner": null, + "hypothesis": "An entry from before the work log, which the page does not show.", + "change": "Not yet specified.", + "expected": null, + "realized": null, + "evidence": null, + "linear": null, + "links": [] } ] } diff --git a/app/scaling/guardrails/page.tsx b/app/scaling/guardrails/page.tsx index 79e8154..8d8731d 100644 --- a/app/scaling/guardrails/page.tsx +++ b/app/scaling/guardrails/page.tsx @@ -12,8 +12,8 @@ export default async function ScalingGuardrailsPage() { const outcomes = snapshot.sections.find((s) => s.id === 'outcomes'); return (
    -
    - +
    + {outcomes ? : null}
    ); diff --git a/app/scaling/loadSnapshot.tsx b/app/scaling/loadSnapshot.tsx index c645c85..dc06375 100644 --- a/app/scaling/loadSnapshot.tsx +++ b/app/scaling/loadSnapshot.tsx @@ -2,19 +2,15 @@ import { readFile } from 'node:fs/promises'; import Link from 'next/link'; import type { ReactNode } from 'react'; -import { Banner } from '../components/ui/Banner'; import { EmptyState } from '../components/ui/EmptyState'; import { Text } from '../components/ui/Text'; -import { isStale, parseSnapshot, type Snapshot } from './snapshot.mjs'; - -const STALE_AFTER_DAYS = 10; -const DAY_MS = 86_400_000; +import { parseSnapshot, type Snapshot } from './snapshot.mjs'; export type Loaded = | { kind: 'unconfigured' } | { kind: 'invalid'; errors: string[] } - | { kind: 'ok'; snapshot: Snapshot; ageDays: number; stale: boolean }; + | { kind: 'ok'; snapshot: Snapshot }; export async function loadSnapshot(): Promise { const path = process.env.SCALING_SNAPSHOT_PATH; @@ -22,9 +18,7 @@ export async function loadSnapshot(): Promise { try { const result = parseSnapshot(JSON.parse(await readFile(path, 'utf8'))); if (!result.ok) return { kind: 'invalid', errors: result.errors }; - const { collectedAt } = result.snapshot; - const ageDays = Math.floor((Date.now() - Date.parse(collectedAt)) / DAY_MS); - return { kind: 'ok', snapshot: result.snapshot, ageDays, stale: isStale(collectedAt, new Date(), STALE_AFTER_DAYS) }; + return { kind: 'ok', snapshot: result.snapshot }; } catch (error) { return { kind: 'invalid', errors: [error instanceof Error ? error.message : String(error)] }; } @@ -47,22 +41,13 @@ export function Unavailable({ loaded }: { loaded: Exclude; title: ReactNode; crumb?: string; action?: ReactNode }) { - const { snapshot, ageDays, stale } = loaded; +export function Header({ title, crumb }: { title: ReactNode; crumb?: string }) { return (
    {crumb ? ( Scaling / {crumb} ) : null} {title} - {action} - - As of {formatTime(snapshot.collectedAt)} ({ageDays === 0 ? 'today' : `${ageDays}d old`}) · manually refreshed - snapshot, not live data. {snapshot.context} - - {stale ? ( - Stale: this snapshot is older than {STALE_AFTER_DAYS} days. Refresh it before using these numbers. - ) : null}
    ); } diff --git a/app/scaling/page.test.tsx b/app/scaling/page.test.tsx index 725ee05..159770f 100644 --- a/app/scaling/page.test.tsx +++ b/app/scaling/page.test.tsx @@ -11,17 +11,37 @@ import fixture from './fixtures/synthetic.v2.json'; import ScalingGuardrailsPage from './guardrails/page'; import ScalingPage from './page'; +// No network in tests: the live archive size is unavailable, so the page keeps the snapshot's. +vi.mock('./archiveSize', async (original) => ({ ...(await original()), loadArchiveSize: async () => null })); + beforeEach(() => vi.stubEnv('SCALING_SNAPSHOT_PATH', fileURLToPath(new URL('./fixtures/synthetic.v2.json', import.meta.url)))); afterEach(() => vi.unstubAllEnvs()); const area = async (id: string) => renderToStaticMarkup(await ScalingAreaPage({ params: Promise.resolve({ area: id }) })); -it('links the first screen to four detail pages instead of listing metric rows', async () => { +it('ends the first screen with the DA link, and no guardrails or benchmark links', async () => { const html = renderToStaticMarkup(await ScalingPage()); - for (const page of ['da', 'execution', 'state', 'guardrails']) expect(html).toContain(`href="/scaling/${page}"`); - expect(html).not.toContain('href="/scaling/networking"'); + expect(html).toContain('DA by workload'); + // The guardrails page stays, unlinked, while the first screen leaves guardrails out. + expect(html).not.toMatch(/href="\/scaling\/guardrails"|Guardrails<\/h2>|SLOs/); + expect(html).not.toMatch(/href="\/benchmark|href="\/scaling\/networking"/); expect(html).not.toContain('Blob utilization'); - expect(html).toContain('href="/scaling/runs"'); + expect(html).not.toContain('href="/scaling/runs"'); + expect(html).toMatch(/]*>synthetic-testnet capacity<\/h1>/); + expect(html).not.toMatch(/As of|manually refreshed|Synthetic fixture: every number/); +}); + +it('orders the first screen: freshness, headline, ladder, ranked limits, subsystems by workload, work, links', async () => { + const html = renderToStaticMarkup(await ScalingPage()); + const order = ['Usage to 2026-01-02', 'Proven capacity', 'How close are we, and to what?', 'What breaks first', 'Subsystems by workload', 'Work log', 'DA by workload'].map((h) => html.indexOf(h)); + expect(order.every((i, k) => i > (order[k - 1] ?? -1))).toBe(true); + expect(html).not.toMatch(/Subsystem detail|>Progress<|Ceilings by bottleneck|Usage data as of/); +}); + +it('dates snapshot data without a stale banner', async () => { + // The fixture is far older than any staleness window. + expect(renderToStaticMarkup(await ScalingGuardrailsPage())).toContain('Usage data as of 2026-01-02'); + for (const html of [renderToStaticMarkup(await ScalingPage()), renderToStaticMarkup(await ScalingGuardrailsPage()), await area('da')]) expect(html).not.toContain('Stale'); }); it('shows every metric row on its detail page', async () => { @@ -31,10 +51,10 @@ it('shows every metric row on its detail page', async () => { } }); -it('renders an area with breadcrumb, as-of header, ceiling, metric details, gaps and a runs slot', async () => { +it('renders an area with breadcrumb, no as-of line, ceiling, metric details, gaps and a runs slot', async () => { const html = await area('da'); expect(html).toContain('href="/scaling">Scaling / DA'); - expect(html).toContain('As of 2026-01-02 00:00 UTC'); + expect(html).not.toContain('As of 2026-01-02'); expect(html).toMatch(/>Ceiling ]*>· Mgas\/s<\/span>/); expect(html).toContain('Lowest: Payments (B20) · 14-blob target, no margin'); expect(html).toContain('>1,517<'); @@ -57,7 +77,7 @@ it('404s an unknown area and networking, which has no ceiling', async () => { for (const id of ['bogus', 'networking']) await expect(area(id)).rejects.toMatchObject({ digest: 'NEXT_HTTP_ERROR_FALLBACK;404' }); }); -it('passes the runtime bench and dashboard URLs to the ladder', async () => { +it('passes the runtime bench and dashboard URLs to the scorecard and footer', async () => { expect(renderToStaticMarkup(await ScalingPage())).not.toContain('Loading bench results'); vi.stubEnv('SCALING_BENCH_BASE_URL', 'https://bench.example.com'); vi.stubEnv('SCALING_DATADOG_DASHBOARD_URL', 'https://dashboards.example.com/scaling'); @@ -66,15 +86,20 @@ it('passes the runtime bench and dashboard URLs to the ladder', async () => { expect(html).toContain('href="https://dashboards.example.com/scaling"'); }); -it('links the first-screen guardrail strip to the guardrails page', async () => { - expect(renderToStaticMarkup(await ScalingPage())).toContain('Guardrails →'); +it('adds bench guardrail rows when a bench host is set, unknown until its results load', async () => { + vi.stubEnv('SCALING_BENCH_BASE_URL', 'https://bench.example.com'); + const html = renderToStaticMarkup(await ScalingGuardrailsPage()); + for (const label of ['Proof-node head lag p99', 'debug_executePayload p99', 'Validator replay headroom']) expect(html).toContain(label); + expect(html).toContain('No qualifying mainnet-mix bench run is loaded.'); }); it('shows guardrail help inline and the outcomes table on the guardrails page', async () => { const html = renderToStaticMarkup(await ScalingGuardrailsPage()); expect(html).toContain('href="/scaling">Scaling / Guardrails'); - expect(html).toContain('As of 2026-01-02 00:00 UTC'); - for (const g of fixture.guardrails) expect(html).toContain(g.help); + expect(html).not.toContain('As of 2026-01-02'); + for (const g of fixture.guardrails) if (g.id !== 'validator-keep-up') expect(html).toContain(g.help); + expect(html).toContain('They must stay within 3 blocks.'); + expect(html).not.toContain('Proof-node head lag p99'); expect(html).not.toContain('Guardrails →'); expect(html).toContain('Inclusion latency p50'); expect(html).toContain('synthetic.inclusion_latency'); diff --git a/app/scaling/page.tsx b/app/scaling/page.tsx index b9d928a..0fec28a 100644 --- a/app/scaling/page.tsx +++ b/app/scaling/page.tsx @@ -2,11 +2,8 @@ import Link from 'next/link'; import { Text } from '../components/ui/Text'; -import { CeilingCards } from './components/CeilingCards'; -import { Guardrails } from './components/Guardrails'; -import { Ladder } from './components/Ladder'; -import { Progress } from './components/Progress'; -import { withDaWorkloads } from './da/workloads'; +import { Scorecard } from './components/Scorecard'; +import { loadArchiveSize, withArchiveSize } from './archiveSize'; import { Header, loadSnapshot, Unavailable } from './loadSnapshot'; // The snapshot is a runtime file, so render per request rather than at build. @@ -16,21 +13,17 @@ export default async function ScalingPage() { const loaded = await loadSnapshot(); if (loaded.kind !== 'ok') return ; - const { snapshot } = loaded; - const ceilings = withDaWorkloads(snapshot.ceilings); + const snapshot = withArchiveSize(loaded.snapshot, await loadArchiveSize()); + const dashboardUrl = process.env.SCALING_DATADOG_DASHBOARD_URL; return (
    -
    Runs →} /> - - - - - - - +
    + - Experiment results: Benchmarks ·{' '} - Load tests + + DA by workload + {dashboardUrl ? Mainnet load, gas limit, DA ceiling : null} +
    ); diff --git a/app/scaling/proven/capacity.test.ts b/app/scaling/proven/capacity.test.ts index 6fa8279..c4c0a6a 100644 --- a/app/scaling/proven/capacity.test.ts +++ b/app/scaling/proven/capacity.test.ts @@ -1,20 +1,49 @@ import { expect, it } from 'vitest'; -import { loadProvenCapacity } from './capacity'; +import { loadProvenCapacity, PAYLOADS, type Workload } from './capacity'; const NOW = Date.parse('2026-02-01T00:00:00Z'); const DAY = 86_400_000; +const HTML = ''; type Spec = { dir: string; mgas: number; daysAgo: number; + workload?: Workload; ref?: string | null; comparable?: boolean; delivered?: number; scenario?: string; benchmarkRun?: string; fail?: string; + // false: written before the step pipeline, so no pipeline-result.json. + pipeline?: boolean; + missing?: string[]; + // Older load-test-result.json files have no block_latency. + noLatency?: boolean; + withinTarget?: boolean | null; +}; + +const LATENCY = { meanMs: 3, p50Ms: 2, p99Ms: 9, maxMs: 12 }; +const STEPS = (s: Spec) => [ + { name: 'sequencer', enabled: true, status: 'passed', reason: null, durationSeconds: 900, outputs: {} }, + { name: 'validator_replay', enabled: true, status: s.comparable === false ? 'failed' : 'passed', reason: s.comparable === false ? 'replay mismatch' : null, durationSeconds: 130, outputs: {} }, + { name: 'da', enabled: true, status: 'passed', reason: null, durationSeconds: 20, outputs: { target: 14, blobsPerL1Slot: 15.5, blobTargetGasPerSecond: 1.9e8, withinTarget: s.withinTarget === undefined ? false : s.withinTarget } }, + { name: 'tee_proof', enabled: false, status: 'not-implemented', reason: 'needs a synthetic L1' }, +]; +const REPLAY = { blocksPerSecond: 36.4, newPayload: LATENCY, forkchoice: LATENCY, match: true, blocks: [{ number: 1 }] }; +const PROOFS = { + headLagBlocks: { samples: 3000, p50: 1, p99: 4, max: 9 }, + failedProbes: 0, + methods: { debug_executePayload: { errors: 0, latency: LATENCY } }, + witnessBytes: { samples: 3000, p50: 4096, p99: 8192, max: 9000 }, +}; +const DA = { seconds: 600, gas_used: 6e10, transactions: 1e6, transaction_bytes: 1.5e8, frame_bytes: 6e7, gas_per_second: 1e8, bytes_per_gas: 0.001, compression_ratio: 2.5, blobs_per_l1_slot: 15.5, blob_fill: 0.98, blob_target_gas_per_second: 1.5e8 }; +const STORAGE = { + diskBudgetBytes: 45e12, + archive: { growthBytesPerYear: 1e14, growthBound: 'lower', currentBytes: null, runwayYears: null, runwayBound: 'upper' }, + fullNode: { stateGrowthBytesPerYear: 1e10, historyFootprintBytes: 9e12, historyFootprintBound: 'lower', proofFootprintBytes: 2e12 }, }; // Run outputs are cached by outputDir across calls, so every test uses its own dirs. @@ -24,10 +53,10 @@ function load(specs: Spec[]) { outputDir: s.dir, createdAt: new Date(NOW - s.daysAgo * DAY).toISOString(), testConfig: { - BenchmarkRun: s.benchmarkRun ?? 'mainnet-snapshot', + BenchmarkRun: s.benchmarkRun ?? `run-${s.dir}`, ChainId: 8453, Scenario: s.scenario ?? `sustained-${s.mgas}mgas`, - TransactionPayload: 'mainnet-mix', + TransactionPayload: PAYLOADS[s.workload ?? 'mix'], }, result: { success: true }, })), @@ -35,31 +64,102 @@ function load(specs: Spec[]) { const fetchImpl = async (url: string) => { const path = url.replace('https://bench.example/output/', ''); if (path === 'metadata.json') return Response.json(metadata); - const [dir, file] = path.split('/'); + const [dir, ...rest] = path.split('/'); + const file = rest.join('/'); const s = specs.find((x) => x.dir === dir)!; if (s.fail === file) return new Response('error', { status: 500 }); - if (file === 'validation.json') return Response.json({ comparable: s.comparable ?? true, reasons: [] }); - if (file === 'load-test-result.json') return Response.json({ throughput: { gps: (s.delivered ?? 1) * s.mgas * 1e6 } }); - if (s.ref === null) return new Response(''); + if (s.missing?.includes(file) || (file === 'pipeline-result.json' && s.pipeline === false)) return new Response(HTML); + const delivered = (s.delivered ?? 1) * s.mgas; + switch (file) { + case 'validation.json': return Response.json({ comparable: s.comparable ?? true, reasons: [] }); + case 'load-test-result.json': return Response.json({ + throughput: { gps: delivered * 1e6, tps: delivered * 10, total_submitted: 1000, total_reverted: 155 }, + ...(s.noLatency ? {} : { block_latency: { p50: { secs: 0, nanos: 205_382_593 }, p99: { secs: 1, nanos: 6_265_374 } } }), + }); + case 'pipeline-result.json': return Response.json({ suite: 'synthetic', comparable: s.comparable ?? true, steps: STEPS(s) }); + case 'replay-result.json': return Response.json(REPLAY); + case 'proof-node/replay-result.json': return Response.json({ ...REPLAY, blocksPerSecond: 20, proofs: PROOFS }); + case 'da-result.json': return Response.json(DA); + case 'storage-growth.json': return Response.json(STORAGE); + } + if (s.ref === null) return new Response(HTML); return Response.json({ source: { repository: 'base/base', ref: s.ref ?? 'main', revision: `rev-${dir}` } }); }; return loadProvenCapacity('https://bench.example', fetchImpl as typeof fetch, NOW); } -it('counts only runs built from main', async () => { +it('counts only pipeline runs built from main, with their steps, replays and DA result', async () => { const { current, unknown } = await load([ { dir: 'main-100', mgas: 100, daysAgo: 3 }, { dir: 'branch-html-200', mgas: 200, daysAgo: 2, ref: null }, { dir: 'branch-ref-300', mgas: 300, daysAgo: 1, ref: 'feature' }, + { dir: 'pre-pipeline-400', mgas: 400, daysAgo: 1, pipeline: false }, ]); - expect(current).toEqual({ + expect(current.mix).toEqual({ mgas: 100, atTop: true, - run: { outputDir: 'main-100', mgas: 100, gps: 100e6, passed: true, commit: 'rev-main-100', createdAt: NOW - 3 * DAY }, + run: { + id: 'run-main-100', + outputDir: 'main-100', + workload: 'mix', + mgas: 100, + gps: 100e6, + tps: 1000, + reverted: 0.155, + blockLatency: { p50Ms: 205.382593, p99Ms: 1006.265374 }, + passed: true, + commit: 'rev-main-100', + createdAt: NOW - 3 * DAY, + steps: STEPS({ dir: '', mgas: 0, daysAgo: 0 }), + replay: { blocksPerSecond: 36.4, newPayload: LATENCY, match: true }, + proofNode: { blocksPerSecond: 20, newPayload: LATENCY, match: true, proofs: PROOFS }, + da: { result: DA, target: 14, withinTarget: false }, + storage: STORAGE, + }, }); expect(unknown).toEqual([]); }); +it('keeps each payload separate, with the latest DA run per payload', async () => { + const { current, da, history, latestAt } = await load([ + { dir: 'pay-mix-400', mgas: 400, daysAgo: 3 }, + { dir: 'pay-b20-200', mgas: 200, daysAgo: 3, workload: 'payment' }, + { dir: 'pay-b20-300', mgas: 300, daysAgo: 2, workload: 'payment', delivered: 0.5 }, + { dir: 'pay-b20-old', mgas: 100, daysAgo: 20, workload: 'payment' }, + { dir: 'pay-swap-100', mgas: 100, daysAgo: 1, workload: 'swap', missing: ['da-result.json'] }, + ]); + expect(current.mix).toMatchObject({ mgas: 400, run: { outputDir: 'pay-mix-400' } }); + expect(current.payment).toMatchObject({ mgas: 200, atTop: false, run: { tps: 2000 } }); + expect(current.swap).toMatchObject({ mgas: 100, run: { da: null } }); + expect(da.mix?.outputDir).toBe('pay-mix-400'); + expect(da.payment?.outputDir).toBe('pay-b20-300'); + expect(da.swap).toBeNull(); + expect(history.map((s) => s.mgas)).toEqual([400]); + // The newest counting run, here one without a DA result. + expect(latestAt).toBe(NOW - DAY); +}); + +it('fails a run whose pipeline step failed', async () => { + const { current } = await load([ + { dir: 'step-100', mgas: 100, daysAgo: 3 }, + { dir: 'step-200', mgas: 200, daysAgo: 2, comparable: false }, + ]); + expect(current.mix).toMatchObject({ mgas: 100, atTop: false }); +}); + +it('keeps a run and its other details when only storage-growth.json fails', async () => { + const { current, unknown } = await load([{ dir: 'storage-500', mgas: 100, daysAgo: 1, fail: 'storage-growth.json' }]); + expect(current.mix?.run).toMatchObject({ storage: null, da: { target: 14 }, replay: { match: true } }); + expect(unknown).toEqual([]); +}); + +it('reads missing replay, proof-node and storage files as null, and an unjudged blob target as null', async () => { + const { current } = await load([ + { dir: 'partial-100', mgas: 100, daysAgo: 1, missing: ['replay-result.json', 'proof-node/replay-result.json', 'storage-growth.json'], withinTarget: null }, + ]); + expect(current.mix?.run).toMatchObject({ replay: null, proofNode: null, storage: null, da: { target: 14, withinTarget: null } }); +}); + it('ignores alias copies and non-sustained scenarios', async () => { const { current, unknown } = await load([ { dir: 'cand-100', mgas: 100, daysAgo: 3 }, @@ -67,7 +167,7 @@ it('ignores alias copies and non-sustained scenarios', async () => { { dir: 'cand-glam-200', mgas: 200, daysAgo: 2, scenario: 'glamsterdam-sustained-200mgas' }, { dir: 'cand-scrape-400', mgas: 400, daysAgo: 2, scenario: 'sustained-400mgas-scrape200ms' }, ]); - expect(current).toMatchObject({ mgas: 100, atTop: true }); + expect(current.mix).toMatchObject({ mgas: 100, atTop: true }); expect(unknown).toEqual([]); }); @@ -78,7 +178,7 @@ it('fails runs that are not comparable or deliver under 95% of target', async () { dir: 'fail-150', mgas: 150, daysAgo: 3, delivered: 0.94 }, { dir: 'fail-200', mgas: 200, daysAgo: 3, comparable: false }, ]); - expect(current).toMatchObject({ mgas: 120, atTop: false, run: { gps: 114e6 } }); + expect(current.mix).toMatchObject({ mgas: 120, atTop: false, run: { gps: 114e6 } }); }); it('ignores runs older than 14 days', async () => { @@ -86,7 +186,7 @@ it('ignores runs older than 14 days', async () => { { dir: 'window-100', mgas: 100, daysAgo: 13 }, { dir: 'window-200', mgas: 200, daysAgo: 14 }, ]); - expect(current).toMatchObject({ mgas: 100, atTop: true }); + expect(current.mix).toMatchObject({ mgas: 100, atTop: true }); }); it('uses the latest run at each rate over an older pass', async () => { @@ -95,7 +195,7 @@ it('uses the latest run at each rate over an older pass', async () => { { dir: 'latest-200-old', mgas: 200, daysAgo: 4 }, { dir: 'latest-200-new', mgas: 200, daysAgo: 1, comparable: false }, ]); - expect(current).toMatchObject({ mgas: 100, atTop: false }); + expect(current.mix).toMatchObject({ mgas: 100, atTop: false }); }); it('steps history when proven capacity changes, with compare links', async () => { @@ -134,13 +234,44 @@ it('dates a drop when the higher pass ages out of the window', async () => { ]); }); -it('marks a run unknown when one of its files fails, keeping the rest', async () => { +it('marks a run unknown when one of its files fails or its results are missing, keeping the rest', async () => { const { current, unknown } = await load([ { dir: 'iso-100', mgas: 100, daysAgo: 3 }, { dir: 'iso-200', mgas: 200, daysAgo: 2, fail: 'load-test-result.json' }, + { dir: 'iso-300', mgas: 300, daysAgo: 2, fail: 'da-result.json' }, + { dir: 'iso-400', mgas: 400, daysAgo: 2, missing: ['validation.json'] }, ]); - expect(current).toMatchObject({ mgas: 100, atTop: true }); - expect(unknown).toEqual(['iso-200']); + // A failed detail file leaves the run counted, without its details. + expect(current.mix).toMatchObject({ mgas: 300, atTop: true, run: { replay: null, da: null } }); + expect(unknown.toSorted()).toEqual(['iso-200', 'iso-300', 'iso-400']); +}); + +it('fetches replay, proof-node and DA files only for proven and latest-DA runs', async () => { + const fetched: string[] = []; + const metadata = { runs: ['sel-100', 'sel-200', 'sel-300'].map((dir, i) => ({ + outputDir: dir, createdAt: new Date(NOW - (3 - i) * DAY).toISOString(), + testConfig: { BenchmarkRun: dir, ChainId: 8453, Scenario: `sustained-${(i + 1) * 100}mgas`, TransactionPayload: 'mainnet-mix' }, + })) }; + const fetchImpl = async (url: string) => { + const path = url.replace('https://bench.example/output/', ''); + if (path === 'metadata.json') return Response.json(metadata); + fetched.push(path); + const [dir, ...rest] = path.split('/'); + const s: Spec = { dir, mgas: Number(dir.slice(4)), daysAgo: 0, comparable: dir !== 'sel-300' }; + const file = rest.join('/'); + if (file === 'validation.json') return Response.json({ comparable: s.comparable }); + if (file === 'load-test-result.json') return Response.json({ throughput: { gps: s.mgas * 1e6, tps: 1 } }); + if (file === 'pipeline-result.json') return Response.json({ steps: STEPS(s) }); + if (file === 'provenance.json') return Response.json({ source: { ref: 'main', revision: 'r' } }); + return Response.json(file === 'da-result.json' ? DA : REPLAY); + }; + const { current, da } = await loadProvenCapacity('https://bench.example', fetchImpl as typeof fetch, NOW); + expect(current.mix?.run.outputDir).toBe('sel-200'); + // Without submitted and reverted totals, nothing counts as reverted. + expect(current.mix?.run.reverted).toBe(0); + expect(da.mix?.outputDir).toBe('sel-300'); + const details = fetched.filter((p) => /replay-result|da-result/.test(p)).map((p) => p.split('/')[0]); + expect([...new Set(details)].toSorted()).toEqual(['sel-200', 'sel-300']); }); it('revalidates the run index instead of trusting a cached copy', async () => { @@ -152,3 +283,12 @@ it('revalidates the run index instead of trusting a cached copy', async () => { await loadProvenCapacity('https://bench.example', fetchImpl as typeof fetch, NOW); expect(inits.get('https://bench.example/output/metadata.json')?.cache).toBe('no-cache'); }); + +it('reads block latency from load-test-result.json as milliseconds, and its absence as null', async () => { + const { current } = await load([ + { dir: 'latency-100', mgas: 100, daysAgo: 1 }, + { dir: 'old-200', mgas: 200, daysAgo: 1, noLatency: true, workload: 'swap' }, + ]); + expect(current.mix?.run.blockLatency).toEqual({ p50Ms: 205.382593, p99Ms: 1006.265374 }); + expect(current.swap?.run.blockLatency).toBeNull(); +}); diff --git a/app/scaling/proven/capacity.ts b/app/scaling/proven/capacity.ts index 7c20db1..30faab2 100644 --- a/app/scaling/proven/capacity.ts +++ b/app/scaling/proven/capacity.ts @@ -1,14 +1,83 @@ import type { BenchmarkRun, BenchmarkRuns } from '../runs/lib/types'; +export const PAYLOADS = { mix: 'mainnet-mix', payment: 'b20-transfer-existing', swap: 'uniswap-v3-swap' } as const; +export type Workload = keyof typeof PAYLOADS; + +export type PipelineStep = { + name: string; + enabled: boolean; + status: 'passed' | 'failed' | 'skipped' | 'not-implemented'; + reason?: string | null; + durationSeconds?: number; + outputs?: Record; +}; +type Latency = { p50Ms: number; p99Ms: number; maxMs: number }; +type Counts = { samples: number; p50: number; p99: number; max: number }; +export type Replay = { blocksPerSecond: number; newPayload: Latency; match: boolean }; +export type Proofs = { + headLagBlocks: Counts; + failedProbes: number; + methods: Record; + witnessBytes: Counts; +}; +// `base-bench da` writes snake_case. +export type DaResult = { + seconds: number; + gas_used: number; + transactions: number; + transaction_bytes: number; + frame_bytes: number; + gas_per_second: number; + bytes_per_gas: number; + compression_ratio: number; + blobs_per_l1_slot: number; + blob_fill: number; + blob_target_gas_per_second: number; +}; +// `target` and `withinTarget` come from the pipeline's DA step; the blob target is recorded, not gating. +export type Da = { result: DaResult; target: number | null; withinTarget: boolean | null }; + +// `storage-growth.json`: disk growth measured over the run, projected to a year at its gas rate. +export type Storage = { + diskBudgetBytes: number; + // Proofs are null when the proofs disk usage could not be measured. + bytesPerGas: { history: number; proofs: number | null; archive: number }; + current: { state: number }; + archive: { growthBytesPerYear: number; growthBound: string; currentBytes: number | null; runwayYears: number | null; runwayBound: string }; + fullNode: { + stateGrowthBytesPerYear: number; + historyWindowSeconds: number; + historyFootprintBytes: number; + historyFootprintBound: string; + proofWindowSeconds: number; + proofFootprintBytes: number; + }; +}; + export type Run = { + id: string; outputDir: string; + workload: Workload; mgas: number; gps: number; + tps: number; + // Share of submitted transactions that reverted; they still use gas. + reverted: number; passed: boolean; commit: string; createdAt: number; + steps: PipelineStep[]; + replay: Replay | null; + proofNode: (Replay & { proofs: Proofs | null }) | null; + da: Da | null; + storage: Storage | null; + // load-test-result.json's block_latency: transaction send to inclusion. + blockLatency: { p50Ms: number; p99Ms: number } | null; }; +// Reverted transactions count toward gas/s but not toward transactions per second. +export const successfulTps = (run: Run) => run.tps * (1 - run.reverted); + export type Proven = { mgas: number; atTop: boolean; run: Run }; export type Step = { @@ -19,15 +88,29 @@ export type Step = { compareUrl: string | null; }; -export type ProvenCapacity = { current: Proven | null; history: Step[]; unknown: string[] }; +// `history` is the mainnet mix's; `da` is the latest counting run per workload in the window whose DA +// step passed; `latestAt` is when the newest counting run was created. Replay, proof-node and DA results are loaded only for the runs in `current` and `da`: +// replay files list every block, and every other run would download them for nothing. +export type ProvenCapacity = { + current: Record; + da: Record; + history: Step[]; + latestAt: number | null; + unknown: string[]; +}; const WINDOW_MS = 14 * 24 * 60 * 60 * 1000; const PASS_RATIO = 0.95; const CONCURRENCY = 6; const SCENARIO = /^sustained-(\d+)mgas$/; +const WORKLOADS = Object.keys(PAYLOADS) as Workload[]; -// Run outputs never change once written. `null` marks a run not built from main. +// Run outputs never change once written. `null` marks a run that does not count: not built from +// main, or written before the step pipeline. const runCache = new Map(); +const detailCache = new Map>(); + +export const runHref = (run: Run) => `/scaling/runs/bench/${encodeURIComponent(run.id)}`; export async function loadProvenCapacity( baseUrl: string, @@ -49,16 +132,40 @@ export async function loadProvenCapacity( unknown.push(meta.outputDir); } }); - return { current: provenAt(runs, now), history: history(runs, now), unknown }; + const inWindow = (r: Run) => r.createdAt > now - WINDOW_MS && r.createdAt <= now; + const daPassed = (r: Run) => r.steps.some((s) => s.name === 'da' && s.status === 'passed'); + const latestDa = (rs: Run[]) => rs.filter((r) => inWindow(r) && daPassed(r)).sort((a, b) => b.createdAt - a.createdAt)[0]; + const of = (rs: Run[], w: Workload) => rs.filter((r) => r.workload === w); + const selected = new Set(WORKLOADS.flatMap((w) => [provenAt(of(runs, w), now)?.run, latestDa(of(runs, w))]).filter((r) => r !== undefined)); + await forEachLimit([...selected], CONCURRENCY, async (run) => { + try { + if (!detailCache.has(run.outputDir)) detailCache.set(run.outputDir, await loadDetails(root, run, fetchImpl)); + } catch { + unknown.push(run.outputDir); + } + }); + const full = runs.map((r) => ({ ...r, ...detailCache.get(r.outputDir) })); + const daRun = (w: Workload) => { + const run = latestDa(of(full, w)); + return run?.da ? run : null; + }; + return { + current: Object.fromEntries(WORKLOADS.map((w) => [w, provenAt(of(full, w), now)])) as ProvenCapacity['current'], + da: Object.fromEntries(WORKLOADS.map((w) => [w, daRun(w)])) as ProvenCapacity['da'], + history: history(of(full, 'mix'), now), + latestAt: runs.length ? Math.max(...runs.map((r) => r.createdAt)) : null, + unknown, + }; } function candidates({ runs }: BenchmarkRuns): BenchmarkRun[] { + const payloads: string[] = Object.values(PAYLOADS); const byDir = new Map(); for (const run of runs) { const c = run.testConfig; if ( c.ChainId === 8453 && - c.TransactionPayload === 'mainnet-mix' && + payloads.includes(String(c.TransactionPayload)) && SCENARIO.test(String(c.Scenario)) && !String(c.BenchmarkRun).endsWith('--latest') ) { @@ -68,31 +175,65 @@ function candidates({ runs }: BenchmarkRuns): BenchmarkRun[] { return [...byDir.values()]; } +// A missing file comes back as the host's HTML fallback with a 200, so non-JSON means missing. +async function getJson(fetchImpl: typeof fetch, url: string) { + const res = await fetchImpl(url); + if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`); + return res.json().catch(() => null); +} + async function loadRun(root: string, meta: BenchmarkRun, fetchImpl: typeof fetch): Promise { - const get = async (file: string) => { - const res = await fetchImpl(`${root}${meta.outputDir}/${file}`); - if (!res.ok) throw new Error(`Failed to fetch ${file}: ${res.status}`); - return res; - }; - const [validation, load, provenance] = await Promise.all([ - get('validation.json').then((r) => r.json()), - get('load-test-result.json').then((r) => r.json()), - // Only main-tracking runs write provenance.json; for others the host serves its HTML fallback with a 200. - get('provenance.json').then((r) => r.json().catch(() => null)), - ]); - if (provenance?.source?.ref !== 'main') return null; + const get = (file: string) => getJson(fetchImpl, `${root}${meta.outputDir}/${file}`); + const [pipeline, provenance] = await Promise.all([get('pipeline-result.json'), get('provenance.json')]); + if (!pipeline || provenance?.source?.ref !== 'main') return null; + const [validation, load] = await Promise.all([get('validation.json'), get('load-test-result.json')]); + if (!validation || !load) throw new Error(`Missing results for ${meta.outputDir}`); const mgas = Number(SCENARIO.exec(String(meta.testConfig.Scenario))![1]); const gps: number = load.throughput.gps; return { + id: String(meta.testConfig.BenchmarkRun), outputDir: meta.outputDir, + workload: WORKLOADS.find((w) => PAYLOADS[w] === meta.testConfig.TransactionPayload)!, mgas, gps, + tps: load.throughput.tps, + reverted: load.throughput.total_submitted ? (load.throughput.total_reverted ?? 0) / load.throughput.total_submitted : 0, + blockLatency: load.block_latency?.p50 && load.block_latency?.p99 ? { p50Ms: durationMs(load.block_latency.p50), p99Ms: durationMs(load.block_latency.p99) } : null, passed: validation.comparable === true && gps >= PASS_RATIO * mgas * 1e6, commit: provenance.source.revision, createdAt: Date.parse(meta.createdAt), + steps: pipeline.steps, + replay: null, + proofNode: null, + da: null, + storage: null, }; } +async function loadDetails(root: string, run: Run, fetchImpl: typeof fetch): Promise> { + const get = (file: string) => getJson(fetchImpl, `${root}${run.outputDir}/${file}`); + const [replay, proofNode, da, storage] = await Promise.all([ + get('replay-result.json'), + get('proof-node/replay-result.json'), + get('da-result.json'), + // Newer than the other files and only shown as context, so its failure must not drop them. + get('storage-growth.json').catch(() => null), + ]); + const daOutputs = run.steps.find((s) => s.name === 'da')?.outputs; + return { + replay: replay && pickReplay(replay), + proofNode: proofNode && { ...pickReplay(proofNode), proofs: proofNode.proofs ?? null }, + da: da && { result: da, target: (daOutputs?.target as number) ?? null, withinTarget: (daOutputs?.withinTarget as boolean) ?? null }, + storage, + }; +} + +// Rust Durations serialize as { secs, nanos }. +const durationMs = (d: { secs: number; nanos: number }) => d.secs * 1e3 + d.nanos / 1e6; + +// replay-result.json also lists every block's timings; keep only the summary. +const pickReplay = ({ blocksPerSecond, newPayload, match }: Replay): Replay => ({ blocksPerSecond, newPayload, match }); + function provenAt(runs: Run[], t: number): Proven | null { const latest = new Map(); for (const run of runs) { diff --git a/app/scaling/snapshot.mjs b/app/scaling/snapshot.mjs index 531f036..a1ecefe 100644 --- a/app/scaling/snapshot.mjs +++ b/app/scaling/snapshot.mjs @@ -36,27 +36,37 @@ * projectId: string, issueId?: string, state: string, progress: number, scopeDone: number, * scopeTotal: number, targetDate?: string, updatedAt: string, url: string * }} LinearStatus + * @typedef {'DA' | 'Disk' | 'Execution' | 'Proving' | 'Latency'} WorkCategory * @typedef {{ - * area: Area, title: string, status: string, owner: string | null, hypothesis: string, change: string, - * expected: string | null, realized: string | null, evidence: Link | null, linear: LinearStatus | null, - * links: Link[] + * title: string, status: string, category?: WorkCategory, effect?: string | null, links?: Link[], + * owner?: string | null, area?: Area, hypothesis?: string, change?: string, + * expected?: string | null, realized?: string | null, evidence?: Link | null, linear?: LinearStatus | null * }} WorkItem * @typedef {{ * schemaVersion: 2, network: string, collectedAt: string, context: string, windows: Record, * ladder: Ladder, ceilings: Ceiling[], guardrails: Guardrail[], * translations: { gasPerPayment: Constant | null, gasPerSwap: Constant | null }, + * usage?: { transactionsPerSecond: Usage }, + * policy?: { runwayYears?: { fullNode: number, archive: number, help?: string, source?: Link } }, + * production?: { + * archiveBytes?: { value: number, asOf: string, source: string, note?: string }, + * stateBytesPerGas?: { value: number, window?: string, source?: string } + * }, * sections: Section[], work: WorkItem[] * }} Snapshot + * @typedef {{ value: number, window: string, help: string, source?: Link }} Usage */ const STATUSES = ['observed', 'partial', 'unavailable', 'not-measured']; const TAGS = ['measured', 'modeled', 'estimated', 'not-measured', 'not-modeled', 'not-established']; const VALUED_TAGS = ['measured', 'modeled', 'estimated']; const AREAS = ['da', 'execution', 'state', 'networking']; +// Work log statuses follow Linear's project states; impact categories are the page's limit names. +export const WORK_STATUSES = ['in-progress', 'planned', 'done']; +export const WORK_CATEGORIES = ['DA', 'Disk', 'Execution', 'Proving', 'Latency']; // Each section renders on its area page, or on the guardrails page for outcomes. const SECTION_IDS = ['da', 'execution', 'state', 'outcomes']; const INPUT_STATUSES = ['observed', 'configured', 'provisional', 'pending']; -const DAY_MS = 86_400_000; // Captures a bench run id, or a load test's network and timestamp. export const RUN_PAGE = /^https:\/\/[^/]+\/scaling\/runs\/(?:bench\/([^/?#]+)|load\/([^/?#]+)\/([^/?#]+))\/?(?:[?#].*)?$/; const V1_MESSAGE = @@ -213,6 +223,47 @@ export function parseSnapshot(data) { windowRef(c, 'window', path); } + // Optional: without it the page heads with gas per second instead of transactions per second. + if (data.usage !== undefined) { + const tps = obj(obj(data, 'usage', 'snapshot'), 'transactionsPerSecond', 'usage'); + const path = 'usage.transactionsPerSecond'; + if (!(Number.isFinite(tps.value) && tps.value >= 0)) fail(`${path}.value`, 'expected a non-negative number'); + if (!Object.hasOwn(windows, tps.window)) fail(`${path}.window`, 'expected a key of windows'); + str(tps, 'help', path); + if (tps.source !== undefined) link(tps.source, `${path}.source`); + } + + // Optional disk inputs: without them the disk rows say which input they need instead of a ceiling. + const positive = (o, key, path) => { + if (!(Number.isFinite(o[key]) && o[key] > 0)) fail(`${path}.${key}`, 'expected a positive number'); + }; + if (data.policy !== undefined) { + const policy = obj(data, 'policy', 'snapshot'); + if (policy.runwayYears !== undefined) { + const runway = obj(policy, 'runwayYears', 'policy'); + positive(runway, 'fullNode', 'policy.runwayYears'); + positive(runway, 'archive', 'policy.runwayYears'); + if (runway.help !== undefined) str(runway, 'help', 'policy.runwayYears'); + if (runway.source !== undefined) link(runway.source, 'policy.runwayYears.source'); + } + } + if (data.production !== undefined) { + const production = obj(data, 'production', 'snapshot'); + if (production.archiveBytes !== undefined) { + const archive = obj(production, 'archiveBytes', 'production'); + positive(archive, 'value', 'production.archiveBytes'); + date(archive, 'asOf', 'production.archiveBytes'); + str(archive, 'source', 'production.archiveBytes'); + if (archive.note !== undefined) str(archive, 'note', 'production.archiveBytes'); + } + if (production.stateBytesPerGas !== undefined) { + const state = obj(production, 'stateBytesPerGas', 'production'); + positive(state, 'value', 'production.stateBytesPerGas'); + if (state.window !== undefined && !Object.hasOwn(windows, state.window)) fail('production.stateBytesPerGas.window', 'expected a key of windows'); + if (state.source !== undefined) str(state, 'source', 'production.stateBytesPerGas'); + } + } + const metric = (m, path) => { if (!isObj(m)) return fail(path, 'expected object'); str(m, 'label', path); @@ -250,16 +301,21 @@ export function parseSnapshot(data) { date(l, 'updatedAt', path); if (typeof l.url !== 'string' || !l.url.startsWith('https://')) fail(`${path}.url`, 'expected https URL'); }; + // Work log entries need a title and a status; the page shows only WORK_STATUSES. Older entries keep their + // area, hypothesis, change, expected, realized, evidence and linear fields, now optional but still checked. list(data, 'work', 'snapshot').forEach((w, i) => { const path = `work[${i}]`; if (!isObj(w)) return fail(path, 'expected object'); - oneOf(w, 'area', path, AREAS); - for (const key of ['title', 'status', 'hypothesis', 'change']) str(w, key, path); - for (const key of ['owner', 'expected', 'realized']) nullableStr(w, key, path); - if (w.evidence !== null) link(w.evidence, `${path}.evidence`); - else if (w.realized !== null) fail(`${path}.evidence`, 'required when realized is set'); - if (w.linear !== null) linear(w.linear, `${path}.linear`); - list(w, 'links', path).forEach((l, j) => link(l, `${path}.links[${j}]`)); + for (const key of ['title', 'status']) str(w, key, path); + if (w.area !== undefined) oneOf(w, 'area', path, AREAS); + if (w.category !== undefined) oneOf(w, 'category', path, WORK_CATEGORIES); + if (w.area === undefined && w.category === undefined) fail(`${path}.category`, 'required without area'); + for (const key of ['hypothesis', 'change']) if (w[key] !== undefined) str(w, key, path); + for (const key of ['owner', 'expected', 'realized', 'effect']) if (w[key] !== undefined) nullableStr(w, key, path); + if (w.evidence != null) link(w.evidence, `${path}.evidence`); + else if (w.realized != null) fail(`${path}.evidence`, 'required when realized is set'); + if (w.linear != null) linear(w.linear, `${path}.linear`); + if (w.links !== undefined) list(w, 'links', path).forEach((l, j) => link(l, `${path}.links[${j}]`)); }); return errors.length ? { ok: false, errors } : { ok: true, snapshot: data }; @@ -278,12 +334,3 @@ export function deriveBinding(ceilings) { } return binding; } - -/** - * @param {string} collectedAt - * @param {Date} now - * @param {number} [days] - */ -export function isStale(collectedAt, now, days = 10) { - return now.getTime() - Date.parse(collectedAt) > days * DAY_MS; -} diff --git a/app/scaling/snapshot.test.ts b/app/scaling/snapshot.test.ts index f446d15..5d8bff2 100644 --- a/app/scaling/snapshot.test.ts +++ b/app/scaling/snapshot.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -import { deriveBinding, isStale, parseSnapshot } from './snapshot.mjs'; +import { deriveBinding, parseSnapshot } from './snapshot.mjs'; // Synthetic data only: real snapshots are internal and never committed. function fixture() { @@ -24,6 +24,36 @@ describe('parseSnapshot', () => { expect(result.snapshot.work[1].linear).toBeNull(); }); + it('treats usage as optional, and checks its value, window and help when present', () => { + const { usage, ...withoutUsage } = fixture(); + expect(errorsOf(withoutUsage)).toEqual([]); + const bad = { ...withoutUsage, usage: { transactionsPerSecond: { ...usage.transactionsPerSecond, value: -1, window: 'year', help: '' } } }; + expect(errorsOf(bad)).toEqual([ + 'usage.transactionsPerSecond.value: expected a non-negative number', + 'usage.transactionsPerSecond.window: expected a key of windows', + 'usage.transactionsPerSecond.help: expected non-empty string', + ]); + }); + + it('treats the disk policy and production inputs as optional, and checks them when present', () => { + const { policy, production, ...without } = fixture(); + expect(errorsOf(without)).toEqual([]); + const bad = { + ...without, + policy: { runwayYears: { ...policy.runwayYears, fullNode: 0 } }, + production: { + archiveBytes: { ...production.archiveBytes, asOf: 'soon', source: '' }, + stateBytesPerGas: { ...production.stateBytesPerGas, window: 'decade' }, + }, + }; + expect(errorsOf(bad)).toEqual([ + 'policy.runwayYears.fullNode: expected a positive number', + 'production.archiveBytes.asOf: expected ISO timestamp', + 'production.archiveBytes.source: expected non-empty string', + 'production.stateBytesPerGas.window: expected a key of windows', + ]); + }); + it('rejects schema version 1 and names the migration', () => { const errors = errorsOf({ ...fixture(), schemaVersion: 1 }); expect(errors).toHaveLength(1); @@ -60,6 +90,23 @@ describe('parseSnapshot', () => { ]); }); + it('accepts a work log entry with only title, status, category, effect, links and owner, and checks each', () => { + const data = fixture(); + data.work.push({ title: 'Synthetic planned work', status: 'planned', category: 'Execution', effect: null, links: [{ label: 'Linear', url: 'https://example.com/linear' }], owner: 'Synthetic owner' }); + expect(errorsOf(data)).toEqual([]); + data.work.push( + { title: 'Bad category', status: 'planned', category: 'Gas' }, + { title: 'No category', status: 'planned' }, + { title: 'Bad link', status: 'planned', category: 'DA', effect: 3, links: [{ label: 'Doc', url: 'http://example.com' }] }, + ); + expect(errorsOf(data)).toEqual([ + 'work[4].category: expected one of DA, Disk, Execution, Proving, Latency', + 'work[5].category: required without area', + 'work[6].effect: expected string or null', + 'work[6].links[0].url: expected https URL', + ]); + }); + it('rejects a missing ceiling area and a duplicate guardrail id', () => { const data = fixture(); data.ceilings.pop(); @@ -144,13 +191,3 @@ describe('deriveBinding', () => { expect(deriveBinding(ceilings)).toBeNull(); }); }); - -describe('isStale', () => { - const collectedAt = fixture().collectedAt; - - it('flags snapshots older than the threshold', () => { - expect(isStale(collectedAt, new Date('2026-01-13T00:00:00Z'))).toBe(true); - expect(isStale(collectedAt, new Date('2026-01-11T00:00:00Z'))).toBe(false); - expect(isStale(collectedAt, new Date('2026-01-13T00:00:00Z'), 20)).toBe(false); - }); -});