interactive AI curriculum · 2026

The AI Iceberg

Nine browser demos that dig down through seventy years of artificial intelligence — minimax at the surface, agents at the bottom — with every algorithm implemented from scratch in vanilla JavaScript and genuinely running in the tab.

vanilla JSzero dependencieszero build step 10 standalone HTML filescanvas rendering self-hosted gateway53 browser tests
Open the demos See a real run
01 — what it does

Nine algorithms you can poke at, none of them faked

Most "learn AI" pages show you a picture of an algorithm. These run it. Open the spam classifier and it fits a naive Bayes model on page load and re-scores your sentence on every keystroke. Open the neural network and you watch a loss curve sit flat for eight hundred epochs and then break as the net finally solves XOR. Open the digit recogniser, draw a 7 with your finger, and a network that trained in your browser two seconds ago tells you what you drew.

The nine demos are ordered as an iceberg: Classical AI (search and hand-written rules), Machine Learning (models fitted from data), Neural Networks, Deep Learning, Generative AI, and Agentic AI at the bottom. The ordering is the argument: each layer is the previous one with a constraint removed, and the demos are built so you can feel the difference rather than take it on faith.

Everything is client-side. There is no server, no framework, no CDN and no build step — ten HTML files with their CSS and JavaScript inline, which run identically from a Vercel URL or by double-clicking the file. The only network call anywhere is the two pages that deliberately put a frontier model next to the toy one for comparison.

From scratch

Minimax, TF-IDF, naive Bayes, gradient boosting, backprop, Adam, AUC, a Markov chain and an agent loop — all hand-written. No ML libraries exist in the browser, and none were faked.

Honest substitutions

Where a Python library has no browser equivalent, the page says so in a dashed box: boosted stumps stand in for XGBoost, an MLP for a CNN, array transforms for pandas.

Verified, not asserted

A Playwright suite plays 12 full games of tic-tac-toe, checks the boosted model rediscovers hidden drivers, and draws digits with a synthetic mouse.

02 — architecture

One design system, ten self-contained pages, no runtime

The shipped artefact is ten HTML files that depend on nothing. To avoid pasting the same stylesheet ten times by hand, the sources live in _src/ and a 20-line Python script inlines the design system and the dataset at author time. That script is a writing convenience, not a build step: delete it and every page still runs.

sources · _src/
*.src.html × 10page markup + the algorithm, inline
_ds-iceberg.csstextthe single design system
_digits_data.js400 sklearn digits, 8×8, inlined literal
_src/_build.py · pastes CSS + data into every page
shipped · repo root and site/demos/
index.htmlhub, ordered by layer
9 demo pagesCSS + JS inline · zero imports
each page containsdesign system · algorithm implementation · DOM UI · canvas drawing · a "how it works" panel
at runtime, in the visitor's browser
runtime · the browser is the only dependency
7 pagesfully offline · training happens in requestAnimationFrame chunks so the tab stays responsive
2 pages → /api/chatPOST /api/chat · no key in the page; a serverless function attaches it server-side, translates the Anthropic-shaped request to the gateway’s OpenAI-compatible schema, clamps max_tokens to 1024 and rejects oversized payloads · every parse in try/catch with a visible failure state
verification drives the built files, not the sources
tests · tests/
verify.mjs53 behavioural assertions in headless Chromium
mobile.mjs375 px overflow + focus-ring audit on all 10 pages
docs/run-*.txtthe raw log quoted on this page
03 — real output

A real run, captured 5 September 2026

run date 2026-09-05 suite 53/53 passed viewport audit 10/10 pages screenshots unretouched

The screenshots below were captured by the same script that runs the tests: it opened each built page, waited for training to actually finish, drew a digit with a synthetic mouse, and photographed the result. The numbers in them were produced by the code in this repository.

The hub page, showing six iceberg layers with project cards
index.html — the hub, layers 01 to 06 with the waterline between hand-written rules and learned ones.
Loss curve and decision-boundary heatmap after the network solved XOR
neural-net.html — solved XOR, loss 0.0004, plateau break detected at epoch 810. The heatmap is the XOR checkerboard the network learned.
A hand-drawn 7 classified by the in-browser network
digit-recognizer.html — a 7 drawn with a synthetic mouse, classified correctly by a network trained in-tab moments earlier. 100% train / 96.7% held-out.
Feature importance bars beside the hidden risk formula
churn-predictor.html — importance bars beside the formula the model was never shown. It ranked contract type, autopay and late payments on top, and the pure-noise feature last.
headline numbers from this run
demomeasuredwhat it means
tictactoe12 games · 0 human winsminimax is unbeatable, as claimed
plant-doctor11 asked · 15 skippedshort-circuiting really does cut the consultation
spam-classifier100% train · 83% held-outheld-out set is 6 messages; the page says so
data-cleaner32 city strings → 6casing chaos collapsed by normalising before grouping
churn-predictorAUC 0.754 vs 0.764 ceilingwithin 0.01 of what any model could reach on this noise
neural-netloss 0.0016 · 4/4 correctXOR solved live, plateau break at epoch 810
digit-recognizer96.7% held-out · 7/7 freehandtrained in-tab on 400 real sklearn digits
markov216 contexts · seed-stablesame seed, same passage, every time
mini-agentparser correct · rejects codecalc("alert(1)") throws instead of executing
raw log · docs/run-2026-09-05.txt, verbatim
AI ICEBERG — verification run
date: 2026-09-05 01:32 PDT
host: macOS 15.1 · node v26.3.0 · chromium (playwright)

$ node tests/verify.mjs
PASS  index: 9 cards  ·  9 cards
PASS  index: all links resolve
PASS  index: no js errors
PASS  tictactoe: human never wins  ·  12 games · 12 AI wins · 0 draws
PASS  tictactoe: search counter works  ·  last move nodes=36
PASS  tictactoe: no js errors
PASS  plant-doctor: all-no gives fallback
PASS  plant-doctor: short-circuits  ·  asked 11, skipped 15
PASS  plant-doctor: yes-yes-yes diagnoses rot  ·  R1 · primary matchOverwatering → root rotStop watering now. 
PASS  plant-doctor: reuses cached answers  ·  recalled 2
PASS  plant-doctor: no js errors
PASS  spam: trained on load  ·  train 100% / test 83% of 6
PASS  spam: classifies both ways  ·  SPAM / HAM
PASS  spam: shows evidence tokens  ·  8 tokens
PASS  spam: no js errors
PASS  cleaner: raw table renders 15  ·  15 rows
PASS  cleaner: casing chaos present  ·  32 distinct city strings
PASS  cleaner: dedupes + normalises  ·  188 rows, 6 cities
PASS  cleaner: 8 pipeline steps  ·  8 steps
PASS  cleaner: chart drawn on canvas  ·  110171 painted px
PASS  cleaner: city summary rows  ·  6 rows
PASS  cleaner: no js errors
PASS  churn: AUC beats chance  ·  AUC 0.754, acc 72.6%
PASS  churn: rediscovers true drivers  ·  ranking: contract_type > autopay > late_payments > support_tickets > tenure_months > num_services > monthly_charge > avg_login_days
PASS  churn: near the noise ceiling  ·  model 0.754 vs oracle 0.764
PASS  churn: noise feature ranks low  ·  avg_login_days at #8
PASS  churn: top-5 risk table  ·  5 rows
PASS  churn: no js errors
PASS  neuralnet: solves XOR  ·  state=solved loss=0.00168 epoch=2,475
PASS  neuralnet: all 4 predictions correct  ·  4/4
PASS  neuralnet: plateau break detected  ·  epoch 810
PASS  neuralnet: boundary heatmap painted  ·  255 distinct colours
PASS  neuralnet: pause works
PASS  neuralnet: no js errors
PASS  digits: train accuracy >= 90%  ·  train 100% / held-out 96.7%
PASS  digits: held-out sane  ·  96.7%
PASS  digits: 3 example buttons
PASS  digits: examples classified right  ·  3/3
PASS  digits: freehand drawing classifies  ·  vertical stroke read as 1
PASS  digits: no js errors
PASS  markov: trains on load  ·  216 contexts
PASS  markov: generates  ·  62 words
PASS  markov: seed is reproducible
PASS  markov: different seed differs
PASS  markov: short-corpus message
PASS  markov: markov side fills locally  ·  the ice remembers every winter it has ever held. T
PASS  markov: API failure handled gracefully  ·  Could not reach the model — Failed to fetch.The Markov side needs no n
PASS  markov: no js errors
PASS  agent: 5 tools listed  ·  5 tools
PASS  agent: arithmetic parser correct
PASS  agent: parser rejects code  ·  threw: expected a number at position 0
PASS  agent: API failure handled gracefully  ·  step 1failederrorCould not reach the model: Failed to fetch The loop stops here 
PASS  agent: no js errors

53/53 passed

$ node tests/mobile.mjs
PASS  index             scrollW=375 vs 375  focus=solid 2px  
PASS  tictactoe         scrollW=375 vs 375  focus=solid 2px  
PASS  plant-doctor      scrollW=375 vs 375  focus=solid 2px  
PASS  spam-classifier   scrollW=375 vs 375  focus=solid 2px  
PASS  data-cleaner      scrollW=375 vs 375  focus=solid 2px  
PASS  churn-predictor   scrollW=375 vs 375  focus=solid 2px  
PASS  neural-net        scrollW=375 vs 375  focus=solid 2px  
PASS  digit-recognizer  scrollW=375 vs 375  focus=solid 2px  
PASS  markov            scrollW=375 vs 375  focus=solid 2px  
PASS  mini-agent        scrollW=375 vs 375  focus=solid 2px  
no horizontal overflow at 375px

$ node tests/site.mjs
PASS  site: no external resources
PASS  site: contains no script
PASS  site: no storage API calls
PASS  site: no <form>
PASS  site: no external fonts
PASS  site: no unfilled placeholders
PASS  site: minimax excerpt matches source
PASS  site: run log quoted verbatim
PASS  site desktop: no overflow  ·  1200 vs 1200
PASS  site desktop: all images load  ·  4 images
PASS  site desktop: no page errors
PASS  site: 6 numbered sections present  ·  ,what,architecture,output,decisions,run,tour
PASS  site: focus ring visible  ·  solid 2px
PASS  site: links to the demos
PASS  site: demos/ copy matches root pages
PASS  site: demo hub opens from site/  ·  The AI Iceberg
PASS  site: demo hub has no errors
PASS  site mobile: no overflow  ·  375 vs 375
PASS  site mobile: all images load  ·  4 images
PASS  site mobile: no page errors

20/20 passed

The two pages that call the model gateway are exercised for their failure path here, not their success path: the test machine has no credentials, so the suite asserts that both pages degrade to a readable error instead of crashing. Their success path needs a host that injects auth, and this page will not pretend otherwise.

04 — key decisions

Ten calls worth defending

Rendered from docs/decisions.md, which carries the full log.

Data cleaner dedupes on order_id, not on whole rows

the duplicate rows differ by stray whitespace, so a whole-row dedupe would silently double revenue; this is the bug the page exists to demonstrate.

Churn displays an oracle AUC ceiling (0.764) beside the model's AUC (0.754)

because the risk formula is synthetic and known, the page can prove the model is near-optimal rather than merely asserting it; that is impossible on real data and too good to leave out.

XOR net uses sigmoid + MSE at lr 0.9 rather than a faster tanh/cross-entropy setup

the slow configuration is the one that shows a visible plateau before the break, and the plateau is the point of the page.

Digit recogniser inlines real sklearn.load_digits samples (40 per class) rather than synthesising digits

provenance is stated in a comment; fabricated training data would have made the accuracy numbers meaningless.

Digit crop margin set to 1.06, chosen by measurement not intuition

1.28 "looked right" and scored 23% round-trip accuracy against 95% for 1.06, because the corpus digits fill their 8×8 frame edge to edge.

Training-time augmentation was tried and reverted

random ±1-cell shifts dropped freehand accuracy 7/7 → 5/7 and held-out 96.7% → 93.3%; a 1-pixel shift on an 8×8 grid destroys 12.5% of the image.

The agent uses a JSON-in-text ReAct protocol rather than a native tools API

the brief fixed the request body to {model, max_tokens, messages}, and a text protocol fits that exactly while making the parse-and-recover step visible on the page.

The agent's calculator is a hand-written recursive-descent parser, never eval

model output is untrusted input; this is also the security point the page makes in prose, so the code had to match it.

Verification is a real headless-browser suite, not a read-through

53 assertions across the ten pages; it caught a TDZ crash that silently disabled the digit page's example buttons and a canvas-resample bug that made them misclassify.

The parallel session's build was parked, not deleted

another Claude session was building the same project in this folder and its _shared.css overwrote mine mid-run; its files are intact under _other-session/ and my stylesheet was renamed to a non-.css extension to end the collision.

05 — how to run it

There is nothing to install to use it

The demos need no server, no package manager and no network. Node and Playwright are needed only if you want to re-run the test suite.

run the demos
# any of these work — the pages are plain files open index.html # macOS xdg-open index.html # Linux python3 -m http.server 8000 # or serve them, if you prefer
run the test suite
npm i -D playwright && npx playwright install chromium node tests/verify.mjs # 53 behavioural assertions node tests/mobile.mjs # 375px overflow + focus rings # already have Playwright elsewhere? point at it instead of installing: PLAYWRIGHT_MODULE=/abs/path/to/playwright/index.mjs node tests/verify.mjs
rebuild the pages after editing a source
# edit _src/*.src.html or _src/_ds-iceberg.csstext, then: python3 _src/_build.py # inlines CSS + data → root and site/demos/ python3 _src/build_site.py # regenerates this documentation page
deploy the documentation site
cd site && vercel --prod

Deploying site/ ships the documentation and a copy of all ten demos under /demos/. There is no build command and no framework preset to choose — Vercel serves the files as they are.

configure

Seven demos never touch the network and need no configuration at all. The two that do — markov and mini-agent — post to /api/chat, a serverless function in api/chat.js that holds the only credential in the system. It does not call Anthropic: it calls the author’s own gateway at api.gariyuuu.com (Qwen3-8B, served under the name Yuu no Sekai), translating the Anthropic-shaped request the pages send into the gateway’s OpenAI-compatible schema and translating the reply back, so no page logic changed:

vercel env add GARIYUU_API_KEY     # Production, Preview, Development
vercel --prod                      # env vars are baked in at deploy time

Until that variable is set the endpoint answers 500 and both pages show a readable error rather than failing silently; a 429 or 402 from upstream surfaces as "the demo is over its limit". No key ever reaches the browser. The pages use no cookies, localStorage or sessionStorage at all.

06 — code tour

The five files that carry the project

Excerpts are extracted from the source files by the page generator, so what you read here is what actually ships.

_src/tictactoe.src.html — minimax()
The oldest idea in AI in twenty lines: score the leaves, take the max on your turn and the min on theirs, and un-make every move on one shared array. The nodes++ line is why the page can tell you the search cost half a million positions.
function minimax(b, isAiTurn){
  nodes++;                                  // every visited node = one board state judged
  const w = winner(b);
  if (w === AI) return [1, -1];             // terminal leaves are scored directly
  if (w === HUMAN) return [-1, -1];
  if (!b.includes(EMPTY)) return [0, -1];

  const mark = isAiTurn ? AI : HUMAN;
  let best = isAiTurn ? -2 : 2, bestMove = -1;
  for (let i = 0; i < 9; i++){
    if (b[i] !== EMPTY) continue;
    b[i] = mark;                            // descend one edge of the game tree
    // adversarial assumption: whatever comes back is the opponent's best reply
    const [score] = minimax(b, !isAiTurn);
    b[i] = EMPTY;                           // backtracking — one shared array, no copies
    if (isAiTurn ? score > best : score < best){ best = score; bestMove = i; }
  }
  return [best, bestMove];
}
_src/churn-predictor.src.html — split finding inside boostRound()
XGBoost's actual gain formula over pre-bucketed histograms: scoring a split is a scan of 24 bins instead of 3,000 rows, which is the trick that makes real boosting libraries fast enough to be worth using.
const parent = G * G / (H + LAMBDA);
let best = { gain: -Infinity };
for (let f = 0; f < nf; f++){
  const gb = new Float64Array(BINS + 1), hb = new Float64Array(BINS + 1);
  for (let i = 0; i < n; i++){ const b = bins.idx[i][f]; gb[b] += g[i]; hb[b] += h[i]; }
  let gl = 0, hl = 0;
  for (let b = 0; b < bins.edges[f].length; b++){
    gl += gb[b]; hl += hb[b];
    const gr = G - gl, hr = H - hl;
    if (hl < 1e-6 || hr < 1e-6) continue;
    const gain = gl*gl/(hl+LAMBDA) + gr*gr/(hr+LAMBDA) - parent;
    if (gain > best.gain) best = { gain, f, b, gl, hl, gr, hr };
  }
}
if (best.gain <= 0) return false;
_src/neural-net.src.html — one training step
Backpropagation with no autograd anywhere: the chain rule applied by hand, layer by layer, using matrix helpers written a few lines above it. This is the whole of "learning" on the neural network page.
// ── backprop: chain rule, layer by layer, backwards ──
const err = sub(A2, Y);                                        // dL/dA2 (×2/N below)
const dZ2 = mul(map(err, e => e * 2 / N), map(A2, a => a * (1-a)));
const dW2 = dot(transpose(A1), dZ2), db2 = colSum(dZ2);
const dA1 = dot(dZ2, transpose(W2));
const dZ1 = mul(dA1, map(A1, a => a * (1-a)));
const dW1 = dot(transpose(X), dZ1), db1 = colSum(dZ1);
for (let i=0;i<2;i++) for (let j=0;j<H;j++) W1[i][j] -= lr * dW1[i][j];
for (let j=0;j<H;j++) b1[j] -= lr * db1[j];
for (let i=0;i<H;i++) W2[i][0] -= lr * dW2[i][0];
b2[0] -= lr * db2[0];
_src/digit-recognizer.src.html — toEight(), the crop that decides everything
The most-debugged twelve lines in the repo. A network with no convolutions has no translation invariance, so how your drawing is cropped and centred matters more than the network's architecture — and the margin constant was chosen by measurement, not taste.
// Square the ink's bounding box and centre it. The 1.06 margin was measured, not
// guessed: the corpus digits fill their 8x8 frame edge to edge, so a tight crop
// matches the training distribution and a generous one does not (95% vs 23%
// round-trip accuracy over the 60 held-out digits).
const cx = (minX + maxX + 1) / 2, cy = (minY + maxY + 1) / 2;
const side = Math.max(maxX - minX + 1, maxY - minY + 1) * 1.06;
const x0 = cx - side/2, y0 = cy - side/2, cell = side / 8;
_src/mini-agent.src.html — calc(), the tool that never trusts the model
A recursive-descent arithmetic parser, because the agent page executes what a language model asks for and eval() on model output is how agent demos become security incidents. The test suite asserts calc("alert(1)") throws.
function calc(expr){
  const s = String(expr).replace(/[,$]/g, ""), n = s.length;
  let i = 0;
  const ws = () => { while (i < n && /\s/.test(s[i])) i++; };
  function number(){
    ws();
    if (s[i] === "("){ i++; const v = expression(); ws();
      if (s[i] !== ")") throw new Error("missing closing bracket"); i++; return v; }
    if (s[i] === "-"){ i++; return -number(); }
    if (s[i] === "+"){ i++; return number(); }
    const m = /^\d+(\.\d+)?/.exec(s.slice(i));
    if (!m) throw new Error("expected a number at position " + i);
    i += m[0].length; return parseFloat(m[0]);
  }
  function power(){ const base = number(); ws();
    if (s[i] === "^"){ i++; return Math.pow(base, power()); } return base; }
  function term(){ let v = power(); ws();
    while (i < n && (s[i] === "*" || s[i] === "/" || s[i] === "%")){
      const op = s[i++]; const r = power();
      if ((op === "/" || op === "%") && r === 0) throw new Error("division by zero");
      v = op === "*" ? v * r : op === "/" ? v / r : v % r; ws();
    } return v; }
  function expression(){ let v = term(); ws();
    while (i < n && (s[i] === "+" || s[i] === "-")){
      const op = s[i++]; const r = term(); v = op === "+" ? v + r : v - r; ws();
    } return v; }
  const out = expression(); ws();
  if (i < n) throw new Error("unexpected character '" + s[i] + "'");
  if (!isFinite(out)) throw new Error("result is not finite");
  return String(Math.round(out * 1e10) / 1e10);