ContentsFoundations to FrontierBezhig

ai-lab/tools/viz.py

All lab code. Download this file.

"""Pictures for the AI Lab. Built for you; this file is not part of the exercises.

    python tools/viz.py example
        Builds AI01's running example with YOUR engine, then writes and opens
        m01_autograd/out/graph.html: an animation of the forward pass, then of
        backprop, one node at a time, replaying your own _backward functions.

From your code, after lab step 3 passes:
    import sys; sys.path.insert(0, "../tools"); import viz
    viz.draw_graph(L, "out/graph.html")

train.py --viz calls plot_training() for the XOR loss curve and decision map.

Everything is one self-contained HTML file with no downloads. It uses only
the attributes your engine's contract promises: data, grad, _prev, _op,
label, and _backward.
"""
import html
import json
import sys
import webbrowser
from pathlib import Path

LAB_ROOT = Path(__file__).resolve().parents[1]


# ----------------------------------------------------------------------
# Graph walking (independent of your backward(), so it can show what yours does)
# ----------------------------------------------------------------------
def _children(v):
    seen, out = set(), []
    for c in getattr(v, "_prev", ()):
        if id(c) not in seen:
            seen.add(id(c))
            out.append(c)
    return out


def topo_order(root):
    """Every node reachable from root, each after all of its children.
    Iterative, so deep graphs cannot hit Python's recursion limit."""
    order, seen = [], set()
    stack = [(root, False)]
    while stack:
        v, expanded = stack.pop()
        if expanded:
            order.append(v)
            continue
        if id(v) in seen:
            continue
        seen.add(id(v))
        stack.append((v, True))
        for c in _children(v):
            if id(c) not in seen:
                stack.append((c, False))
    return order


def _name(v, index):
    label = getattr(v, "label", "") or ""
    if label:
        return label
    if not _children(v):                       # an unlabeled leaf is a constant
        return f"{v.data:g}"
    return f"n{index}"


def _fmt(x):
    return f"{x:.4g}"


# ----------------------------------------------------------------------
# The backprop animation
# ----------------------------------------------------------------------
def replay_backward(root):
    """Zero every grad, seed the root, and call each node's _backward in reverse
    topological order, recording how much every call added to each child.
    Leaves the graph with the same grads a fresh backward() would give."""
    order = topo_order(root)
    for v in order:
        v.grad = 0.0
    root.grad = 1.0
    index = {id(v): i for i, v in enumerate(order)}
    steps = []
    for v in reversed(order):
        kids = _children(v)
        if not kids:
            continue                           # a leaf's _backward does nothing
        before = [c.grad for c in kids]
        v._backward()
        deltas = [(index[id(c)], c.grad - b) for c, b in zip(kids, before)]
        steps.append((index[id(v)], deltas, [n.grad for n in order]))
    return order, steps


def _layout(order):
    """Columns by longest path from the root (root on the right), rows by the
    average row of each node's parents, so edges mostly run straight."""
    idx = {id(v): i for i, v in enumerate(order)}
    rank = {id(order[-1]): 0}
    parents = {id(v): [] for v in order}
    for v in reversed(order):
        for c in _children(v):
            rank[id(c)] = max(rank.get(id(c), 0), rank[id(v)] + 1)
            parents[id(c)].append(idx[id(v)])
    max_rank = max(rank.values())
    columns = {}
    for v in order:
        columns.setdefault(max_rank - rank[id(v)], []).append(v)
    row = {}
    for col in sorted(columns, reverse=True):
        nodes = columns[col]
        nodes.sort(key=lambda v: (sum(row[p] for p in parents[id(v)]) / len(parents[id(v)])
                                  if parents[id(v)] else 0.0))
        for r, v in enumerate(nodes):
            row[idx[id(v)]] = r - (len(nodes) - 1) / 2
    return {idx[id(v)]: (col, row[idx[id(v)]]) for col, nodes in columns.items() for v in nodes}


def draw_graph(root, path="out/graph.html", title="Backprop, one node at a time", open_browser=True):
    """Write an interactive HTML animation of the forward and backward pass
    through the graph that ends at `root`. Returns the output path."""
    order, steps = replay_backward(root)
    pos = _layout(order)
    at = {id(v): i for i, v in enumerate(order)}          # identity, never ==
    names = [_name(v, i) for i, v in enumerate(order)]
    nodes = []
    for i, v in enumerate(order):
        col, r = pos[i]
        nodes.append({"id": i, "name": names[i], "op": getattr(v, "_op", "") or "",
                      "data": v.data, "col": col, "row": r,
                      "kids": [at[id(c)] for c in _children(v)]})

    frames = []
    leaves = [i for i, v in enumerate(order) if not _children(v)]
    frames.append({"phase": "forward", "shown": leaves, "active": None,
                   "caption": "Forward pass. Start with the leaves: "
                              + ", ".join(f"{names[i]} = {_fmt(order[i].data)}" for i in leaves)})
    shown = list(leaves)
    for i, v in enumerate(order):
        kids = _children(v)
        if not kids:
            continue
        shown = shown + [i]
        kid_names = [names[at[id(c)]] for c in kids]
        op = getattr(v, "_op", "") or "?"
        if len(kids) == 1:
            expr = f"{op}({kid_names[0]})" if not op.startswith("**") else f"{kid_names[0]}{op}"
        else:
            expr = f" {op} ".join(kid_names)
        frames.append({"phase": "forward", "shown": list(shown), "active": i,
                       "caption": f"Forward: {names[i]} = {expr} = {_fmt(v.data)}"})
    n = len(order)
    grads0 = [0.0] * n
    grads0[n - 1] = 1.0
    frames.append({"phase": "seed", "shown": list(range(n)), "active": n - 1, "grads": grads0,
                   "caption": f"Backward pass. Seed: the output's gradient with respect to itself is 1, "
                              f"so {names[n - 1]}.grad = 1."})
    for active, deltas, grads in steps:
        sent = ", ".join(f"{'+' if d >= 0 else ''}{_fmt(d)} to {names[c]}" for c, d in deltas)
        frames.append({"phase": "backward", "shown": list(range(n)), "active": active,
                       "deltas": deltas, "grads": grads,
                       "caption": f"{names[active]}._backward() with {names[active]}.grad = "
                                  f"{_fmt(grads[active])}: sends {sent}."})
    frames[-1]["caption"] += " Done: every grad is final."

    payload = {"title": title, "nodes": nodes, "frames": frames,
               "final": [{"name": names[i], "op": nodes[i]["op"], "data": order[i].data,
                          "grad": order[i].grad} for i in range(n)]}
    return _write(path, title, GRAPH_BODY, payload, open_browser)


# ----------------------------------------------------------------------
# Training pictures: loss curve and decision map
# ----------------------------------------------------------------------
def plot_training(losses, model, xs, ys, path="out/xor.html", open_browser=True, grid=41, lo=-0.5, hi=1.5):
    """Loss per step, plus the model's output over the input square with the
    training points on top. Blue means the model says +1, red means -1."""
    cells = []
    for gy in range(grid):
        x2 = lo + (hi - lo) * gy / (grid - 1)
        for gx in range(grid):
            x1 = lo + (hi - lo) * gx / (grid - 1)
            cells.append(model([x1, x2]).data)
    points = [{"x": x[0], "y": x[1], "target": t, "pred": model(x).data} for x, t in zip(xs, ys)]
    payload = {"losses": list(losses), "grid": grid, "lo": lo, "hi": hi, "cells": cells, "points": points}
    return _write(path, "XOR training", TRAIN_BODY, payload, open_browser)


# ----------------------------------------------------------------------
def _write(path, title, body, payload, open_browser):
    out = Path(path)
    if not out.is_absolute():
        out = Path.cwd() / out
    out.parent.mkdir(parents=True, exist_ok=True)
    page = (PAGE.replace("__TITLE__", html.escape(title))
                .replace("__BODY__", body)
                .replace("__DATA__", json.dumps(payload).replace("</", "<\\/")))
    out.write_text(page, encoding="utf-8")
    if open_browser:
        webbrowser.open(out.as_uri())
    return out


PAGE = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>
:root { color-scheme: light;
  --surface: #fcfcfb; --panel: #ffffff; --line: #e6e6e3; --text: #0b0b0b; --text2: #52514e; --muted: #8a8a86;
  --blue: #2a78d6; --orange: #eb6834; --red: #e34948; --neutral: #f0efec; --ring: #fcfcfb; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark;
  --surface: #1a1a19; --panel: #232322; --line: #383835; --text: #ffffff; --text2: #c3c2b7; --muted: #8f8e86;
  --blue: #3987e5; --orange: #d95926; --red: #e66767; --neutral: #383835; --ring: #1a1a19; } }
:root[data-theme="dark"] { color-scheme: dark;
  --surface: #1a1a19; --panel: #232322; --line: #383835; --text: #ffffff; --text2: #c3c2b7; --muted: #8f8e86;
  --blue: #3987e5; --orange: #d95926; --red: #e66767; --neutral: #383835; --ring: #1a1a19; }
body { margin: 0; background: var(--surface); color: var(--text); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 1180px; margin: 0 auto; padding: 20px 16px 40px; }
h1 { font-size: 20px; margin: 0 0 4px; } p.sub { color: var(--text2); margin: 0 0 14px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; margin-bottom: 14px; overflow-x: auto; }
.bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
button { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; }
button:hover { border-color: var(--blue); }
.caption { min-height: 2.9em; padding: 8px 10px; border-left: 3px solid var(--blue); background: var(--surface); color: var(--text); }
.count { color: var(--muted); font-variant-numeric: tabular-nums; }
svg text { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; fill: var(--text); }
svg .dim { fill: var(--muted); } svg .axis { stroke: var(--line); } svg .tick { fill: var(--text2); font-size: 11px; }
table { border-collapse: collapse; font-variant-numeric: tabular-nums; }
td, th { padding: 4px 12px; border-bottom: 1px solid var(--line); text-align: right; } th { color: var(--text2); font-weight: 600; }
td:first-child, th:first-child { text-align: left; }
details summary { cursor: pointer; color: var(--text2); }
.tip { position: fixed; pointer-events: none; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; font-size: 13px; display: none; }
.legend { display: flex; gap: 16px; flex-wrap: wrap; color: var(--text2); font-size: 13px; }
.sw { display: inline-block; width: 12px; height: 12px; border-radius: 3px; vertical-align: -1px; margin-right: 5px; }
</style></head>
<body><script>const DATA = __DATA__;</script>
<main>__BODY__</main>
</body></html>"""

GRAPH_BODY = r"""
<h1 id="title"></h1>
<p class="sub">Each box is one Value: its name, its data, and (during backprop) its grad. The circle is the operation that made it.
Arrows run from inputs to the value they helped compute. Use the buttons, or the left and right arrow keys.</p>
<div class="card">
  <div class="bar"><button id="prev">&#9664; Back</button><button id="next">Step &#9654;</button>
  <button id="play">Play</button><button id="reset">Reset</button><span class="count" id="count"></span></div>
  <div class="caption" id="caption"></div>
</div>
<div class="card"><svg id="g"></svg></div>
<details class="card"><summary>Table view (final values)</summary><table id="tbl"></table></details>
"""

GRAPH_SCRIPT = r"""
const W = 150, H = 54, CW = 230, RH = 84, PAD = 50;
const svg = document.getElementById('g');
const N = DATA.nodes, F = DATA.frames;
document.getElementById('title').textContent = DATA.title;
const cols = Math.max(...N.map(n => n.col)) + 1;
const rows = N.map(n => n.row);
const rmin = Math.min(...rows), rmax = Math.max(...rows);
const width = cols * CW + PAD, height = (rmax - rmin) * RH + H + 2 * PAD;
svg.setAttribute('width', width); svg.setAttribute('height', height);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
const X = n => PAD + n.col * CW, Y = n => PAD + (n.row - rmin) * RH;
const NS = 'http://www.w3.org/2000/svg';
const el = (tag, attrs, parent) => { const e = document.createElementNS(NS, tag);
  for (const k in attrs) e.setAttribute(k, attrs[k]); (parent || svg).appendChild(e); return e; };
const fmt = x => { const a = Math.abs(x); return (a !== 0 && (a < 1e-3 || a >= 1e5)) ? x.toExponential(2) : (+x.toPrecision(4)).toString(); };
const edges = [];
for (const n of N) for (const k of n.kids) {
  const c = N[k]; const x1 = X(c) + W, y1 = Y(c) + H / 2, x2 = X(n) - (n.op ? 32 : 0), y2 = Y(n) + H / 2;
  const mx = (x1 + x2) / 2;
  const p = el('path', {d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`, fill: 'none', stroke: 'var(--line)', 'stroke-width': 2});
  const t = el('text', {x: mx, y: (y1 + y2) / 2 - 6, 'text-anchor': 'middle', class: 'dim'}); t.style.display = 'none';
  edges.push({from: k, to: n.id, path: p, label: t});
}
const box = N.map(n => {
  const g = el('g', {});
  if (n.op) { el('line', {x1: X(n) - 12, y1: Y(n) + H / 2, x2: X(n), y2: Y(n) + H / 2, stroke: 'var(--line)', 'stroke-width': 2}, g);
    el('circle', {cx: X(n) - 22, cy: Y(n) + H / 2, r: 12, fill: 'var(--surface)', stroke: 'var(--muted)', 'stroke-width': 1.5}, g);
    const o = el('text', {x: X(n) - 22, y: Y(n) + H / 2 + 4, 'text-anchor': 'middle'}, g); o.textContent = n.op.length > 4 ? n.op.slice(0, 4) : n.op; }
  const r = el('rect', {x: X(n), y: Y(n), width: W, height: H, rx: 6, fill: 'var(--panel)', stroke: 'var(--muted)', 'stroke-width': 1.5}, g);
  const nm = el('text', {x: X(n) + 10, y: Y(n) + 17, 'font-weight': 700}, g); nm.textContent = n.name;
  const d = el('text', {x: X(n) + 10, y: Y(n) + 33}, g);
  const gr = el('text', {x: X(n) + 10, y: Y(n) + 48}, g);
  return {g, r, d, gr};
});
let k = 0, timer = null;
function render() {
  const f = F[k]; const shown = new Set(f.shown);
  document.getElementById('caption').textContent = f.caption;
  document.getElementById('count').textContent = `frame ${k + 1} of ${F.length}`;
  N.forEach((n, i) => { const b = box[i];
    b.g.style.opacity = shown.has(i) ? 1 : 0.25;
    b.d.textContent = shown.has(i) ? `data ${fmt(n.data)}` : 'data ?';
    b.gr.textContent = f.grads ? `grad ${fmt(f.grads[i])}` : '';
    b.gr.setAttribute('fill', f.grads && f.grads[i] !== 0 ? 'var(--orange)' : 'var(--text2)');
    const act = f.active === i;
    b.r.setAttribute('stroke', act ? 'var(--blue)' : 'var(--muted)');
    b.r.setAttribute('stroke-width', act ? 3 : 1.5); });
  const sending = new Map((f.deltas || []).map(([c, d]) => [c, d]));
  for (const e of edges) {
    const hot = f.phase === 'backward' && e.to === f.active && sending.has(e.from);
    const fwd = f.phase === 'forward' && e.to === f.active;
    e.path.setAttribute('stroke', hot ? 'var(--orange)' : fwd ? 'var(--blue)' : 'var(--line)');
    e.path.setAttribute('stroke-width', hot || fwd ? 3 : 2);
    e.path.setAttribute('stroke-dasharray', hot ? '6 4' : 'none');
    e.label.style.display = hot ? '' : 'none';
    if (hot) { const d = sending.get(e.from); e.label.textContent = (d >= 0 ? '+' : '') + fmt(d); }
  }
}
function go(d) { k = Math.min(F.length - 1, Math.max(0, k + d)); render(); }
document.getElementById('next').onclick = () => go(1);
document.getElementById('prev').onclick = () => go(-1);
document.getElementById('reset').onclick = () => { k = 0; render(); };
document.getElementById('play').onclick = () => {
  if (timer) { clearInterval(timer); timer = null; document.getElementById('play').textContent = 'Play'; return; }
  document.getElementById('play').textContent = 'Pause';
  timer = setInterval(() => { if (k >= F.length - 1) { clearInterval(timer); timer = null;
    document.getElementById('play').textContent = 'Play'; } else go(1); }, 1300); };
document.addEventListener('keydown', e => { if (e.key === 'ArrowRight') go(1); if (e.key === 'ArrowLeft') go(-1); });
const tbl = document.getElementById('tbl');
tbl.innerHTML = '<tr><th>value</th><th>op</th><th>data</th><th>grad</th></tr>' +
  DATA.final.map(r => `<tr><td>${r.name}</td><td>${r.op || 'leaf'}</td><td>${fmt(r.data)}</td><td>${fmt(r.grad)}</td></tr>`).join('');
render();
"""

TRAIN_BODY = r"""
<h1>XOR: loss and decision map</h1>
<p class="sub">Left: the loss after each gradient-descent step. Right: what the trained network outputs for every input in the square.
Blue means it says +1, red means -1, and gray is the boundary where it is undecided.</p>
<div style="display:flex; gap:14px; flex-wrap:wrap">
<div class="card" style="flex:1 1 420px"><div class="bar"><strong>Loss</strong>
  <button id="scale">Linear scale</button><span class="count" id="lossnote"></span></div><svg id="loss"></svg></div>
<div class="card" style="flex:1 1 360px"><div class="bar"><strong>Decision map</strong></div><svg id="map"></svg>
  <div class="legend"><span><span class="sw" style="background:var(--blue)"></span>output near +1</span>
  <span><span class="sw" style="background:var(--neutral);border:1px solid var(--line)"></span>output near 0</span>
  <span><span class="sw" style="background:var(--red)"></span>output near -1</span></div></div>
</div>
<details class="card"><summary>Table view</summary><table id="tbl"></table></details>
<div class="tip" id="tip"></div>
"""

TRAIN_SCRIPT = r"""
const NS = 'http://www.w3.org/2000/svg';
const el = (tag, attrs, parent) => { const e = document.createElementNS(NS, tag);
  for (const k in attrs) e.setAttribute(k, attrs[k]); parent.appendChild(e); return e; };
const tip = document.getElementById('tip');
const showTip = (ev, text) => { tip.textContent = text; tip.style.display = 'block';
  tip.style.left = (ev.clientX + 14) + 'px'; tip.style.top = (ev.clientY + 12) + 'px'; };
const hideTip = () => { tip.style.display = 'none'; };
const L = DATA.losses; let logScale = true;
function drawLoss() {
  const svg = document.getElementById('loss'); svg.innerHTML = '';
  const w = 460, h = 280, ml = 58, mr = 14, mt = 12, mb = 36;
  svg.setAttribute('viewBox', `0 0 ${w} ${h}`); svg.setAttribute('width', '100%');
  const pos = L.filter(v => v > 0);
  const lo = logScale ? Math.log10(Math.min(...pos)) : 0, hi = logScale ? Math.log10(Math.max(...pos)) : Math.max(...L);
  const tx = i => ml + (w - ml - mr) * i / Math.max(1, L.length - 1);
  const ty = v => { const u = logScale ? Math.log10(Math.max(v, 1e-300)) : v; return mt + (h - mt - mb) * (1 - (u - lo) / ((hi - lo) || 1)); };
  const ticks = logScale ? (() => { const t = [], a = Math.floor(lo), b = Math.ceil(hi);
                             const every = Math.max(1, Math.ceil((b - a) / 5));   // at most about 6 labels
                             for (let e = b; e >= a; e -= every) t.push(Math.pow(10, e)); return t; })()
                         : [0, 0.25, 0.5, 0.75, 1].map(f => f * hi);
  for (const t of ticks) { const y = ty(t); if (y < mt - 1 || y > h - mb + 1) continue;
    el('line', {x1: ml, x2: w - mr, y1: y, y2: y, class: 'axis'}, svg);
    const tt = el('text', {x: ml - 6, y: y + 4, 'text-anchor': 'end', class: 'tick'}, svg);
    tt.textContent = logScale ? (t >= 0.01 ? +t.toPrecision(2) : '1e' + Math.round(Math.log10(t))) : +t.toPrecision(2); }
  for (const f of [0, 0.5, 1]) { const i = Math.round(f * (L.length - 1));
    const tt = el('text', {x: tx(i), y: h - mb + 18, 'text-anchor': 'middle', class: 'tick'}, svg); tt.textContent = i; }
  const xl = el('text', {x: (ml + w - mr) / 2, y: h - 4, 'text-anchor': 'middle', class: 'tick'}, svg); xl.textContent = 'step';
  el('path', {d: L.map((v, i) => `${i ? 'L' : 'M'}${tx(i)},${ty(v)}`).join(''), fill: 'none', stroke: 'var(--blue)', 'stroke-width': 2, 'stroke-linejoin': 'round'}, svg);
  const cross = el('line', {y1: mt, y2: h - mb, stroke: 'var(--muted)', 'stroke-width': 1, visibility: 'hidden'}, svg);
  const dot = el('circle', {r: 5, fill: 'var(--blue)', stroke: 'var(--ring)', 'stroke-width': 2, visibility: 'hidden'}, svg);
  const hit = el('rect', {x: ml, y: mt, width: w - ml - mr, height: h - mt - mb, fill: 'transparent'}, svg);
  hit.addEventListener('mousemove', ev => { const r = svg.getBoundingClientRect();
    const px = (ev.clientX - r.left) * w / r.width; const i = Math.round((px - ml) / (w - ml - mr) * (L.length - 1));
    const j = Math.max(0, Math.min(L.length - 1, i));
    cross.setAttribute('x1', tx(j)); cross.setAttribute('x2', tx(j)); cross.setAttribute('visibility', 'visible');
    dot.setAttribute('cx', tx(j)); dot.setAttribute('cy', ty(L[j])); dot.setAttribute('visibility', 'visible');
    showTip(ev, `step ${j}  ·  loss ${L[j].toPrecision(4)}`); });
  hit.addEventListener('mouseleave', () => { cross.setAttribute('visibility', 'hidden'); dot.setAttribute('visibility', 'hidden'); hideTip(); });
  document.getElementById('scale').textContent = logScale ? 'Linear scale' : 'Log scale';
  document.getElementById('lossnote').textContent = `${L[0].toPrecision(3)} → ${L[L.length - 1].toPrecision(3)}` + (logScale ? '  (log scale shows late progress)' : '');
}
document.getElementById('scale').onclick = () => { logScale = !logScale; drawLoss(); };
drawLoss();
function mix(a, b, t) { return a.map((v, i) => Math.round(v + (b[i] - v) * t)); }
function hex(c) { const s = getComputedStyle(document.documentElement).getPropertyValue(c).trim(); return [1, 3, 5].map(i => parseInt(s.slice(i, i + 2), 16)); }
function drawMap() {
  const svg = document.getElementById('map'); svg.innerHTML = '';
  const S = 360, m = 30, G = DATA.grid, lo = DATA.lo, hi = DATA.hi, cell = (S - 2 * m) / G;
  svg.setAttribute('viewBox', `0 0 ${S} ${S}`); svg.setAttribute('width', '100%'); svg.style.maxWidth = S + 'px';
  const neu = hex('--neutral'), pos = hex('--blue'), neg = hex('--red');
  const px = x => m + (x - lo) / (hi - lo) * (S - 2 * m), py = y => S - m - (y - lo) / (hi - lo) * (S - 2 * m);
  DATA.cells.forEach((v, n) => { const gx = n % G, gy = Math.floor(n / G);
    const t = Math.max(-1, Math.min(1, v)); const c = t >= 0 ? mix(neu, pos, t) : mix(neu, neg, -t);
    const r = el('rect', {x: m + gx * cell, y: S - m - (gy + 1) * cell, width: cell + 0.5, height: cell + 0.5, fill: `rgb(${c})`}, svg);
    const x1 = lo + (hi - lo) * gx / (G - 1), x2 = lo + (hi - lo) * gy / (G - 1);
    r.addEventListener('mousemove', ev => showTip(ev, `input (${x1.toFixed(2)}, ${x2.toFixed(2)})  →  output ${v >= 0 ? '+' : ''}${v.toFixed(3)}`));
    r.addEventListener('mouseleave', hideTip); });
  for (const t of [0, 0.5, 1]) { const a = el('text', {x: px(t), y: S - 10, 'text-anchor': 'middle', class: 'tick'}, svg); a.textContent = t;
    const b = el('text', {x: 14, y: py(t) + 4, 'text-anchor': 'middle', class: 'tick'}, svg); b.textContent = t; }
  for (const p of DATA.points) {
    el('circle', {cx: px(p.x), cy: py(p.y), r: 10, fill: 'var(--panel)'}, svg);   // halo so the point stands off its region
    const c = el('circle', {cx: px(p.x), cy: py(p.y), r: 7, fill: p.target > 0 ? 'var(--blue)' : 'var(--red)', stroke: 'var(--text)', 'stroke-width': 1.5}, svg);
    c.addEventListener('mousemove', ev => showTip(ev, `(${p.x}, ${p.y})  target ${p.target > 0 ? '+1' : '-1'}  prediction ${p.pred.toFixed(3)}`));
    c.addEventListener('mouseleave', hideTip); }
}
drawMap();
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', drawMap);
document.getElementById('tbl').innerHTML = '<tr><th>input</th><th>target</th><th>prediction</th></tr>' +
  DATA.points.map(p => `<tr><td>(${p.x}, ${p.y})</td><td>${p.target > 0 ? '+1' : '-1'}</td><td>${p.pred.toFixed(3)}</td></tr>`).join('');
"""

# Each page body carries its own script, after the elements it draws into.
GRAPH_BODY = GRAPH_BODY + "\n<script>" + GRAPH_SCRIPT + "</script>"
TRAIN_BODY = TRAIN_BODY + "\n<script>" + TRAIN_SCRIPT + "</script>"


# ----------------------------------------------------------------------
def _example():
    """AI01's running example, built with the learner's own engine."""
    sys.path.insert(0, str(LAB_ROOT / "m01_autograd"))
    from engine import Value
    a, b, c = Value(3.0, label="a"), Value(-2.0, label="b"), Value(5.0, label="c")
    e = a * b
    e.label = "e"
    d = e + b
    d.label = "d"
    L = d * c
    L.label = "L"
    L.backward()
    return L


def main(argv):
    if len(argv) >= 2 and argv[1] == "example":
        path = draw_graph(_example(), LAB_ROOT / "m01_autograd" / "out" / "graph.html",
                          title="AI01 running example: L = (a*b + b) * c")
        print("wrote", path)
    else:
        print(__doc__)


if __name__ == "__main__":
    main(sys.argv)