Charts

Scaling Law

A four-state log-log scaling plot that fits L = E + A·C^(−α) to the runs it is given — one power law per model family, exponent, irreducible loss and R² printed, with the stretch past the largest run hatched and projected.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  CHART_SCALING_LAW_MIN_RUNS,
  type ChartScalingLawData,
  type ChartScalingLawRun,
} from "./chart-scaling-law.contract"

export interface ChartScalingLawProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartScalingLawData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-scaling-law.json

Prompt

Build a React + TypeScript + Tailwind "ChartScalingLaw" research card in plain
SVG with zod. Not recharts: both axes are logarithmic, the curve on them is
fitted by the component rather than supplied, and the mark that matters most —
a hatched region past the last real run — is a statement about evidence, not a
series.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; computeLabel: string; lossLabel: string;
    runs: { compute: number > 0; loss: number > 0; family?: string;
            params?: number; tokens?: number; label?: string }[];
    extrapolateTo?: number > 0 }.
- Component props = z.infer of that schema plus height?: number,
  showFamilyFilter?: boolean, onRetry?: () => void and className. No
  hand-written parallel interface.
- Refinements, because these are contract errors and not render-time surprises:
  a ready payload needs at least four runs (L = E + A·C^(−α) has three free
  parameters, so three points interpolate it exactly), and extrapolateTo must
  lie beyond the largest run — the hatched band is an extrapolation, not a
  redraw of the fitted range.
- compute is total training FLOPs (6 · params · tokens, counting active
  parameters for a sparse model); loss is whatever lossLabel names. params and
  tokens are reported in the tooltip and the data table, never fitted: this is
  loss against compute, not the two-variable L(N, D) surface.

Behavior
- Fit, do not accept a fit. For each family: at a candidate floor E the law
  linearises to ln(L − E) = ln A − α·ln C, so A and α come from closed-form
  least squares; only E is searched — a grid over [0, 0.98 · min(loss)) and
  then golden section inside the bracketing cell. The 0.98 ceiling is real:
  as E approaches the best run's loss its residual collapses and the objective
  starts describing the parameterisation instead of the data. Least squares
  runs in log space, which weights a 1% miss at the bottom of the ladder like a
  1% miss at the top. Report R² of that log-space fit, and NaN → "—" when the
  residual is constant.
- Fit each family separately and never pool them: a mixture of model shapes has
  no single exponent, and the whole reason to draw several on one pair of axes
  is to compare their α. A family shorter than the four-run minimum is plotted
  as points with no line and says why.
- Runs with no family collect into one implicit series; when NO run declares a
  family, the series is "All runs" and the filter disappears — a filter with
  one option is furniture.
- Extrapolation is drawn twice over, because two different things are being
  admitted: the hatch starts at the largest run ON THE CARD, while each
  family's line goes dashed at ITS OWN last run. A projection marker sits at
  extrapolateTo as a hollow ring — a filled dot is a run somebody paid for.
  Refuse to project a fit whose exponent came out ≤ 0 and say so: loss rising
  with compute is a broken ladder, not a scaling law.
- Filter: one aria-pressed toggle per family in a role="group", all pressed to
  start. Hiding the last visible family is refused rather than obeyed (that
  button carries aria-disabled and an sr-only hint). Everything downstream
  recomputes from the visible set — the frame, the hatch's left edge, the
  compute shares and every printed verdict.
- Compute shares are apportioned by largest remainder over the visible
  families, so they total exactly 100 in every subset; rounding each share on
  its own is how three families print 97 under one filter state and 101 under
  another.
- Derived readouts, all computed and none hard-coded: percentage of reducible
  loss bought per 10× of compute (1 − 10^(−α)), the compute multiplier that
  halves the remaining gap to the floor (2^(1/α)), the projected loss at the
  target, the spread between projections, and a caveat for any family fitted
  over less than one decade of compute.
- Four first-class branches: loading (skeleton with a falling ladder over its
  own floor, aria-hidden, animate-pulse + motion-reduce:animate-none), empty
  (names the four-run minimum instead of implying a failed fetch, and reports
  runs dropped for a non-positive compute or loss), error (role="alert" plus a
  Try again button only when onRetry exists), ready.
- A ResizeObserver measures the plot box and is disconnected on unmount and on
  node change; it is the only subscription in the component.

Rendering & styling
- Axes: decade gridlines on x labelled "1e22", thinned by a stride so labels
  never collide; 2× and 5× minor rules only while a decade is wide enough. On y
  the ticks come from a mantissa ladder — the finest ladder whose ticks all
  clear a minimum pixel gap wins, because a loss axis spans a fraction of a
  decade and plain decade ticks would print two labels.
- The frame opens far enough to show a fitted floor sitting just under the runs
  (that asymptote is the point of the chart) but not one half a decade below,
  which would squash every run into a stripe.
- Colours come only from tokens: family i takes var(--chart-{(i % 5) + 1}) for
  its points, its law, its floor rule, its projection ring and its legend
  swatch — one formula, five consumers — plus a dash pattern per family so the
  card survives greyscale. Panel is rounded-xl border bg-card; the hatch is
  fill-muted with stroke-muted-foreground lines, since a low-alpha wash alone
  disappears on a near-black card.
- role="img" on the plot with an aria-label and aria-describedby; the only
  controls are the family pills above it, and every number is repeated in an
  sr-only table of runs (rank-sampled to a cap) with the fitted value and the
  residual beside each measured loss.
- cn() merges className, numbers are tabular-nums, and any expression followed
  by a unit is separated with an explicit {" "} — JSX deletes a newline next to
  an expression rather than folding it to a space.

Customization levers
- Plot density: height (200–520) and the y-axis mantissa ladders; drop the
  minor 2×/5× rules for a cleaner frame, or add 3× and 7× for log-paper feel.
- Which blocks to keep: the family filter (showFamilyFilter), the legend's
  projection row, the floor rules, the verdict paragraph — each is independent
  of the plot.
- The fit: FLOOR_STEPS / GOLDEN_STEPS trade search cost for precision; pin the
  floor to a known entropy estimate by skipping the search entirely; or swap
  the objective for Huber in log space, which is what the Chinchilla paper
  does, when a ladder carries a diverged run.
- Minimum ladder length: CHART_SCALING_LAW_MIN_RUNS is exported — raise it to
  six if your reviewers refuse fits on four points.
- The projection: extrapolateTo drives the hatch, the dashed extensions and the
  rings; omit it and the card draws only the measured range. Swap the "halve
  the gap" multiplier for "compute needed to reach loss X" by inverting the law
  — C = ((L − E) / A)^(−1/α).
- Palette: the colour formula cycles five chart tokens; map a fixed token per
  family (brand colours for shipped architectures) by replacing inkFor.

Concepts

  • The fit is the deliverable — the card is handed (compute, loss) pairs and nothing else, so the exponent it prints can be checked against the runs sitting next to it. A pre-fitted line passed in as props is a claim the reader has to take on faith; here the line, the exponent and the residual column all come out of the same four lines of algebra.
  • Three parameters, one of them awkwardA and α are linear in log space once the floor E is fixed, so only E needs searching. That asymmetry is why the component grids E first and refines by golden section: the objective has a wall at E → min(loss) where the best run's residual collapses, and any plain descent walks straight into it.
  • The irreducible term is the honest halfE is the entropy the data has and the model cannot remove. Without it a power law promises zero loss at infinite compute, and every conversation about "just scale it" quietly assumes that. Drawing E as a rule the runs approach and never cross turns the argument into a number.
  • Per-family fits, never pooled — different model shapes have different exponents; averaging them produces a law describing a mixture nobody trained. Each family gets its own colour, its own dash, its own α, and the comparison the axes were drawn for.
  • Extrapolation is admitted twice — the hatched band says "past anything on this card", the dashed line says "past this family's own last run". They are different admissions, they start at different places, and a chart that draws only one of them is over-claiming for the family that stopped early.
  • Every subset totals 100 — compute shares are apportioned by largest remainder over the visible families, so hiding one re-apportions the rest instead of leaving 97 or 101 on screen. Rounding per slice is a defect that survives review because it only shows up in the filter states nobody screenshots.

On This Page