Recovery Race

A multi-line chart that races several optimizers on a shared log-loss axis. You hand it pre-computed loss curves; it plots them as a polyline per optimizer with a synchronised vertical cursor and per-track head dots. Use it to teach optimizer robustness (Adam recovers, SGD plateaus), learning-rate sensitivity, and what a good optimizer looks like when the initialisation is poor.

Recovery race: SGD, Adam, RMSProp.Step 45 of 99. SGD: loss 0.555. Adam: loss 0.079. RMSProp: loss 0.209.
Recovery racestep 045 / 099
0.0010.010.1110010203040506070809099steploss (log)
  • SGDloss 0.555
  • Adamloss 0.079
  • RMSProploss 0.209
Customize
Curves
1.00
Cursor
45
Playback

Installation

npx shadcn@latest add https://craftbits.dev/r/recovery-race.json

Usage

import { RecoveryRace } from "@craft-bits/core";
 
<RecoveryRace
  optimizers={[
    { id: "sgd",     label: "SGD",     lossCurve: sgdLoss,  tone: "warning" },
    { id: "adam",    label: "Adam",    lossCurve: adamLoss, tone: "accent" },
    { id: "rmsprop", label: "RMSProp", lossCurve: rmsLoss,  tone: "success" },
  ]}
/>

Drive playback from outside the component, e.g. synced to a scrollytelling scrubber:

const [step, setStep] = useState(0);
const [playing, setPlaying] = useState(true);
 
<RecoveryRace
  optimizers={runs}
  currentStep={step}
  onCurrentStepChange={setStep}
  playing={playing}
  onPlayingChange={setPlaying}
  playSpeed={40}
/>

Anchor on a specific step to set up a teaching beat:

<RecoveryRace optimizers={runs} defaultCurrentStep={50} />

Understanding the component

  1. Pure plotting primitive — no math inside. The component does not simulate optimizers. You hand it lossCurve: number[] per track, and it renders. Compute curves however you like — analytic, recorded from a real training run, or synthesised for teaching.
  2. Log y-axis with a small floor. Losses are plotted on a log10 axis so the spread between 1e-4 and 1 reads cleanly. Values below 1e-6 are clamped — a curve that genuinely hits zero still draws.
  3. The longest curve sets the race length. Shorter curves stop drawing at their last point; the cursor and scrubber span the longest curve. Mix-and-match runs of different lengths without padding.
  4. One shared cursor. A single vertical dashed line marks currentStep; each optimizer's head dot springs to its position at that step. Pair currentStep with onCurrentStepChange to scrub from outside, or use defaultCurrentStep for uncontrolled anchoring.
  5. Three tones, by intent. accent is the protagonist (e.g. the recovering optimizer). success is a converged baseline. warning is a struggler (the curve that plateaus or overshoots). Pick tones to encode the story, not the optimizer name.
  6. Autoplay is opt-in. playing / defaultPlaying flips a setTimeout loop with playSpeed ms between steps (default 60). Reduced-motion users have autoplay disabled and the cursor snaps to the last step on mount.

Props

PropTypeDefaultDescription
optimizersreadonly RecoveryRaceOptimizer[]Required. Pre-computed loss curves with id, label, lossCurve, optional tone.
currentStepnumberControlled cursor step. Pair with onCurrentStepChange.
defaultCurrentStepnumber0Uncontrolled initial step.
onCurrentStepChange(step: number) => voidFires on every tick and manual scrub.
playingbooleanControlled play state. Pair with onPlayingChange.
defaultPlayingbooleanfalseUncontrolled initial play state.
onPlayingChange(playing: boolean) => voidFires when play / pause flips.
playSpeednumber60Milliseconds between autoplay steps.
transitionTransitionSPRINGS.snapSpring for the cursor and head dots.
classNamestringMerged onto the root <div> via cn().

The RecoveryRaceOptimizer shape:

FieldTypeDescription
idstringUnique key — React key + aria summary token.
labelstringLegend label.
lossCurvereadonly number[]One loss value per step. Non-finite entries are skipped.
tone'accent' | 'success' | 'warning'Trace tone. Defaults to 'accent'.

Accessibility

  • The figure is role="figure" with a labelled title that names the optimizers in the race.
  • An aria-live="polite" summary above the chart announces the current step and per-optimizer loss whenever the step changes — so the race is readable without color.
  • The play / pause button uses aria-pressed; the label flips between "Play race" and "Pause race".
  • The scrubber is a native <input type="range"> with aria-label="Step scrubber" — keyboard arrows nudge the cursor.
  • Each trace pairs a tone swatch with a textual label and a numerical loss in the legend, so color is never the only signal.
  • prefers-reduced-motion: reduce snaps the cursor to the last step on mount and disables autoplay; no per-step motion is played.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/nn/RecoveryRace.tsx). The source was a bespoke stage-machine for the dead-ReLU recovery story (1D neuron, ReLU vs Leaky ReLU, dramatic zero-crossing flash, hand-coded narration). Reframed here into a general multi-line loss-race primitive: stripped the stage state, the ChallengeBtn / SvgLabel dependencies, the inline motion/react imperative tweens, and the lesson-specific narration. The new API takes pre-computed loss curves and plots them on a shared log axis with controlled and uncontrolled currentStep and playing.