summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuren Hao <yurenh2@illinois.edu>2026-07-15 10:29:06 -0500
committerYuren Hao <yurenh2@illinois.edu>2026-07-15 10:29:06 -0500
commit82da86dbd49a093a24e0827331667e8e9e6217df (patch)
tree96c40877ab6888d66c6e7992a8d6bfebb0453ba2
parentbf83f39828fb79428fe65fe0fef49768351afc05 (diff)
Alexi primer deck: 10 slides (arch op-by-op, EP phases, schedule/parallelism, 72M blowup from real logs, loop-gain + beta-window hypotheses, diagnostics table, in-flight outcomes, $50k ladder) + figure scripts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FAPDWQ49M5Ye3NpTndTpn
-rw-r--r--ep_run/build_alexi_deck.py159
-rw-r--r--ep_run/fig_primer_arch.py135
-rw-r--r--ep_run/fig_primer_block.py148
-rw-r--r--ep_run/fig_primer_pack2.py201
-rw-r--r--ep_run/fig_primer_phases.py99
5 files changed, 742 insertions, 0 deletions
diff --git a/ep_run/build_alexi_deck.py b/ep_run/build_alexi_deck.py
new file mode 100644
index 0000000..461b8fb
--- /dev/null
+++ b/ep_run/build_alexi_deck.py
@@ -0,0 +1,159 @@
+"""Alexi technical-primer deck: architecture -> EP update -> schedule -> problems ->
+hypotheses -> diagnostics -> likely outcomes -> $50k plan. 16:9, minimal styling."""
+from pptx import Presentation
+from pptx.util import Inches, Pt
+from pptx.dml.color import RGBColor
+from pptx.enum.text import PP_ALIGN
+
+INK = RGBColor(0x3a, 0x3a, 0x3a); GRAY = RGBColor(0x8a, 0x8a, 0x8a)
+BLUE = RGBColor(0x2c, 0x6f, 0xbb); ORAN = RGBColor(0xd9, 0x5f, 0x02)
+RED = RGBColor(0xb0, 0x3a, 0x2e); GREEN = RGBColor(0x2e, 0x7d, 0x32)
+A = '/home/yurenh2/ept/assets/'
+
+prs = Presentation()
+prs.slide_width = Inches(13.333); prs.slide_height = Inches(7.5)
+BLANK = prs.slide_layouts[6]
+
+def slide():
+ return prs.slides.add_slide(BLANK)
+
+def tbox(s, x, y, w, h):
+ tb = s.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
+ tb.text_frame.word_wrap = True
+ return tb.text_frame
+
+def title(s, text, color=INK):
+ tf = tbox(s, 0.45, 0.22, 12.5, 0.75)
+ p = tf.paragraphs[0]; r = p.add_run(); r.text = text
+ r.font.size = Pt(25); r.font.bold = True; r.font.color.rgb = color
+
+def para(tf, text, size=13, color=INK, bold=False, before=6, bullet=True, first=False):
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
+ p.space_before = Pt(0 if first else before)
+ r = p.add_run(); r.text = ('• ' if bullet else '') + text
+ r.font.size = Pt(size); r.font.color.rgb = color; r.font.bold = bold
+ return p
+
+# ---------- 1 cover ----------
+s = slide()
+tf = tbox(s, 0.9, 2.35, 11.5, 2.6)
+p = tf.paragraphs[0]; r = p.add_run()
+r.text = 'Training transformer LMs without backpropagation'
+r.font.size = Pt(34); r.font.bold = True; r.font.color.rgb = INK
+p2 = tf.add_paragraph(); p2.space_before = Pt(14); r = p2.add_run()
+r.text = 'Standard architecture at inference · equilibrium-propagation gradients in training · GPU now, analog next'
+r.font.size = Pt(17); r.font.color.rgb = GRAY
+p3 = tf.add_paragraph(); p3.space_before = Pt(30); r = p3.add_run()
+r.text = 'Yuren Hao · July 2026 · technical primer'
+r.font.size = Pt(14); r.font.color.rgb = INK
+
+# ---------- 2 architecture ----------
+s = slide(); title(s, 'The model is a stock OLMo2 decoder — op by op')
+s.shapes.add_picture(A + 'fig_primer_block.png', Inches(0.5), Inches(1.0), height=Inches(6.3))
+tf = tbox(s, 5.6, 1.35, 7.3, 5.8)
+para(tf, 'Decoder-only transformer: L12, C512, H8, T256. RMSNorm, RoPE, QK-norm, SwiGLU, untied readout. No biases.', 14, first=True)
+para(tf, 'Two trained instances: 42.75M (TinyStories, 4k BPE) and 72.11M (FineWeb-Edu, 32k BPE).', 14)
+para(tf, 'Nothing in the inference graph is modified for our training method — the checkpoint is indistinguishable in form from a conventionally trained model.', 14, bold=True)
+para(tf, 'Color code, kept through the whole deck: blue = weight×activation matmuls (7 logical — crossbar-mappable on analog hardware); purple = activation×activation (qkᵀ, Av — must be computed on the fly); cream = RMSNorm; gray = elementwise.', 13, GRAY)
+para(tf, 'One honest architecture–method coupling: QK-norm and the final RMSNorm bound internal state scales, which the training estimator relies on. Both are stock components.', 13, GRAY)
+
+# ---------- 3 EP phases ----------
+s = slide(); title(s, 'Training = two settles and a difference measurement')
+s.shapes.add_picture(A + 'fig_primer_phases.png', Inches(0.35), Inches(1.05), height=Inches(6.1))
+tf = tbox(s, 7.35, 1.5, 5.6, 5.6)
+para(tf, 'The stack is treated as a system that settles: each layer gets a state zₗ, and the energy penalizes disagreement between zₗ and fₗ(zₗ₋₁).', 14, first=True)
+para(tf, 'Free phase: settle with no label. The exact minimum IS the ordinary forward pass (E = 0). Inference is untouched.', 14)
+para(tf, 'Nudged phase: weights frozen, states move — the loss pulls the top state with strength β, the whole stack re-settles, every layer picks up a mismatch dₗ.', 14)
+para(tf, 'Update: states frozen, weights move — each layer absorbs its own mismatch: Δθₗ ∝ ⟨dₗ, ∂fₗ/∂θₗ⟩/β. Local quantities only; no global tape.', 14)
+para(tf, 'The nudged state becomes the new free state: today’s pulled answer is tomorrow’s natural forward output.', 14, ORAN, bold=True)
+
+# ---------- 4 schedule ----------
+s = slide(); title(s, 'The update schedule: local, pipelinable — and native to physics')
+s.shapes.add_picture(A + 'fig_p3_schedule.png', Inches(0.45), Inches(1.15), width=Inches(12.4))
+tf = tbox(s, 0.7, 6.55, 12.0, 0.8)
+para(tf, 'Everything that follows is the price of emulating this settle with discrete sweeps on a GPU. Two problems fall out — the physical system has neither.', 14, RED, bold=True, first=True, bullet=False)
+
+# ---------- 5 what happened ----------
+s = slide(); title(s, 'What actually happened at 72M (real run, real logs)')
+s.shapes.add_picture(A + 'fig_p4_blowup.png', Inches(0.4), Inches(1.1), height=Inches(5.5))
+tf = tbox(s, 9.05, 1.35, 4.0, 5.9)
+para(tf, 'Same β = 1e-3: safe for 135k steps, lethal at 195k. Something moved under the schedule.', 13.5, RED, bold=True, first=True)
+para(tf, 'Guards contained it (15k step-skips, no NaN), a governor finished the token budget — but β starved to 2e-5 and best never improved after 185k.', 13)
+para(tf, 'Headline stands: 72.11M × 1.44B tokens fully BP-free, best 3.7117 vs BP twin 3.2884.', 13)
+para(tf, 'The 0.43 gap is a BLOWN-SCHEDULE number, not a method number. The honest rerun is in flight (last slide but one).', 13, bold=True)
+
+# ---------- 6 problem 1 ----------
+s = slide(); title(s, 'Problem 1 — the nudge closes a loop; discrete sweeps can amplify it')
+s.shapes.add_picture(A + 'fig_p5_loop.png', Inches(1.35), Inches(1.15), height=Inches(5.6))
+tf = tbox(s, 0.7, 6.85, 12.0, 0.55)
+para(tf, 'Why the SAME β became lethal: ρ ≈ β·σ(Wₒᵤₜ)²·‖J‖² crossed 1 as σ and ‖J‖ grew with fit depth.', 14, first=True, bullet=False)
+
+# ---------- 7 problem 2 ----------
+s = slide(); title(s, 'Problem 2 — β is squeezed from both sides, and the window narrows')
+s.shapes.add_picture(A + 'fig_p6_window.png', Inches(0.4), Inches(1.1), height=Inches(5.5))
+tf = tbox(s, 9.05, 1.3, 4.0, 6.0)
+para(tf, 'Floor is real and RISING: on the 42M testbed, tail at flat 1e-3 beats decaying to 3e-4 (1.2678 vs 1.2808). Momentum as a substitute for β refuted in 3 doses.', 13, first=True)
+para(tf, 'Ceiling is real and FALLING: measured crossings at 195k (≤1e-3), 222k (≤3e-4), late (~2e-5).', 13)
+para(tf, 'Window-aware recipe: centered ±β estimator (bias O(β²) → ride high) + a loop-gain governor capping β near ITS ceiling.', 13, bold=True)
+para(tf, 'fw72m_cent, in flight, is exactly this recipe.', 13, ORAN, bold=True)
+
+# ---------- 8 diagnostics ----------
+s = slide(); title(s, 'Diagnostics already done (every number is a run you can open in wandb)')
+rows = [
+ ('DDP gradient equivalence', 'cos 0.999999999 vs single-GPU big batch (manual all-reduce, synced guards)'),
+ ('centered + bf16 + DDP', 'cos 1.000000000 vs sequential centered; packed ±β variant identical (relerr 2e-5)'),
+ ('Error-source decomposition', 'within-block read EXACT (1.0000); ALL bias sits in between-block transmission (0.9987 = the whole EP deficit); K=1 degenerates to BP; K3 = K8 (converged, not truncated)'),
+ ('Wall-2 probes — 6 arms @72M', 'damping refuted in 3 doses; lowering β sails through; governor completes the budget'),
+ ('Tail-SNR arms — 7 @42M', 'centered@3e-3 wins CE and cos; ĝ-momentum refuted ×3; Richardson dominated; AdamW = tie'),
+ ('Gap-vs-size ladder — 8 runs, pre-registered', 'gap tracks FIT DEPTH, not width: 0.004 / 0.004 / 0.015 / 0.013 / 0.050 (C128→C512); C128 has gate cos 0.805 yet ZERO gap — direction noise alone costs nothing until the fit is deep'),
+ ('bf16 mixed precision', 'epoch-validated lossless (Δ +0.006), 1.56× wall-clock'),
+ ('Cost of centered', '1.72× step time (1.64× packed); tail-only switching amortizes to ~1.13×'),
+]
+tbl = s.shapes.add_table(len(rows) + 1, 2, Inches(0.45), Inches(1.15), Inches(12.45), Inches(5.9)).table
+tbl.columns[0].width = Inches(3.6); tbl.columns[1].width = Inches(8.85)
+hdr = ('diagnostic', 'verdict')
+for j in range(2):
+ c = tbl.cell(0, j); c.text = hdr[j]
+ c.text_frame.paragraphs[0].runs[0].font.size = Pt(12)
+ c.text_frame.paragraphs[0].runs[0].font.bold = True
+for i, (a, b) in enumerate(rows):
+ for j, t in enumerate((a, b)):
+ c = tbl.cell(i + 1, j); c.text = t
+ r = c.text_frame.paragraphs[0].runs[0]
+ r.font.size = Pt(10.5); r.font.bold = (j == 0)
+ r.font.color.rgb = INK
+
+# ---------- 9 likely outcomes ----------
+s = slide(); title(s, 'In flight right now — and what the outcomes would mean')
+tf = tbox(s, 0.55, 1.2, 12.4, 6.0)
+para(tf, 'stage1b_cent — 42M TinyStories, centered for the FULL epoch (at 30k/58.8k: best 1.3642, ahead of both originals at matched step).', 14, bold=True, first=True)
+para(tf, 'reads: best ≤ ~1.24 ⇒ the 0.050 gap was a noise-recipe account and the zero-gap line extends to C512 · 1.25–1.26 ⇒ a residual needs window management · >1.27 ⇒ an unknown term (falsifier).', 13, GRAY)
+para(tf, 'fw72m_cent — 72M FineWeb, the window-aware recipe from scratch (at 48k/234k: best 3.6890 — already below the original’s FULL-RUN best 3.7117, at 20% of the budget).', 14, bold=True, before=14)
+para(tf, 'registered predictions: no 195k-style blow; honest gap vs BP twin lands 0.25–0.35 (current trajectory suggests better).', 13, GRAY)
+para(tf, 'If both hold: the EP–BP gap is an ENGINEERING account — schedule + estimator — not an architectural tax. The frontier is late-phase signal-to-noise, and we hold validated levers for it.', 14, GREEN, bold=True, before=16)
+para(tf, 'Queued next: 3 seeds at C256/C512 (certify the ladder tiers); the est-switch rule (when to start paying for centered); 300M × 6B.', 13, before=14)
+
+# ---------- 10 the $50k ----------
+s = slide(); title(s, 'How to spend the $50k — a laddered plan with kill-gates')
+rows = [
+ ('rung', 'est. cost', 'what it buys', 'gate to the next rung'),
+ ('300M × 6B tokens (rented H100, days)', '~$0.3k', 'recipe + window governor at 4× params; DDP at scale', 'gap & stability hold'),
+ ('1B × 10B', '~$5k', 'first B-class BP-free LM; the LAST affordable BP twin', 'gap ≤ target at 1B'),
+ ('7B × 20B (flagship)', '~$30k', 'headline result; compare to published baselines (no twin)', '—'),
+ ('reserve', '$10–15k', 'seeds, reruns, ablations, surprises', '—'),
+]
+tbl = s.shapes.add_table(len(rows), 4, Inches(0.45), Inches(1.25), Inches(12.45), Inches(4.2)).table
+widths = (3.6, 1.3, 4.6, 2.95)
+for j, w in enumerate(widths): tbl.columns[j].width = Inches(w)
+for i, row in enumerate(rows):
+ for j, t in enumerate(row):
+ c = tbl.cell(i, j); c.text = t
+ r = c.text_frame.paragraphs[0].runs[0]
+ r.font.size = Pt(12 if i == 0 else 12.5); r.font.bold = (i == 0 or j == 0)
+ r.font.color.rgb = INK
+tf = tbox(s, 0.55, 5.9, 12.4, 1.3)
+para(tf, 'Prices (sourced, July 2026): market H100 $1.87–2.99/GPU·h; AWS p5e $4.97/GPU·h. All estimates include bf16 (1.56×) and EP ≈ 3.2× BP wall-clock; tail-only centered adds ~1.13×.', 12, GRAY, first=True, bullet=False)
+para(tf, 'Alternative one-shot: 3B-Chinchilla ≈ $40k fits the envelope alone — but the ladder buys three publishable points and de-risks the flagship.', 12, GRAY, bullet=False)
+
+prs.save(A + 'alexi_primer_deck.pptx')
+print('deck saved:', A + 'alexi_primer_deck.pptx')
diff --git a/ep_run/fig_primer_arch.py b/ep_run/fig_primer_arch.py
new file mode 100644
index 0000000..1090f45
--- /dev/null
+++ b/ep_run/fig_primer_arch.py
@@ -0,0 +1,135 @@
+"""Primer figure 1: stock architecture + two-phase gradient measurement + PC contrast.
+Vector PDF + PNG preview to ../assets/. Restrained style, no glow."""
+import matplotlib
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+plt.rcParams['svg.fonttype'] = 'none' # keep text editable in PPT
+from matplotlib.patches import FancyBboxPatch
+
+INK = '#3a3a3a'
+GRAY = '#8a8a8a'
+BOXF = '#f4f4f4'
+BOXE = '#9a9a9a'
+BLUE = '#2c6fbb'
+BLUEF = '#eaf1fa'
+ORAN = '#d95f02'
+RED = '#b03a2e'
+CREAM = '#faf6ee'
+CREME = '#c9b895'
+
+fig = plt.figure(figsize=(13.2, 10.0))
+ax = fig.add_axes([0, 0, 1, 1]); ax.set_xlim(0, 100); ax.set_ylim(0, 105); ax.axis('off')
+
+def box(x, y, w, h, fc=BOXF, ec=BOXE, lw=1.0, r=0.6):
+ b = FancyBboxPatch((x, y), w, h, boxstyle=f'round,pad=0.25,rounding_size={r}',
+ fc=fc, ec=ec, lw=lw)
+ ax.add_patch(b); return b
+
+def txt(x, y, s, size=8.5, color=INK, ha='center', va='center', w='normal', style='normal'):
+ ax.text(x, y, s, fontsize=size, color=color, ha=ha, va=va,
+ fontweight=w, fontstyle=style)
+
+def arrow(x0, y0, x1, y1, color=INK, lw=1.2, style='-|>', ms=11):
+ ax.annotate('', xy=(x1, y1), xytext=(x0, y0),
+ arrowprops=dict(arrowstyle=style, color=color, lw=lw,
+ mutation_scale=ms, shrinkA=0.5, shrinkB=0.5))
+
+# ============================= (A) the model =============================
+txt(16, 102.3, '(A) The model — a stock OLMo2-style decoder', 10.5, w='bold', ha='center')
+txt(16, 99.6, 'Inference is one ordinary forward pass.', 8.5, GRAY)
+
+cx = 14
+txt(cx, 93.5, 'logits', 9.5, w='bold')
+arrow(cx, 88.6, cx, 91.8)
+box(cx - 10, 84.6, 20, 4.0); txt(cx, 86.6, r'linear readout $W_{\mathrm{out}}$ (untied)', 8.5)
+arrow(cx, 81.0, cx, 84.2)
+box(cx - 10, 77.0, 20, 4.0); txt(cx, 79.0, 'final RMSNorm', 8.5)
+arrow(cx, 71.6, cx, 76.6)
+
+box(cx - 12.5, 53.0, 25, 18.6, fc='#eeeeee', ec='#777777', lw=1.3)
+txt(cx, 68.4, r'transformer block ($=f_l$) $\times\,12$', 9, w='bold')
+txt(cx, 63.9, r'$h\ =\ z + \mathrm{RMSNorm}(\,\mathrm{Attn}(z)\,)$', 9)
+txt(cx, 59.4, r"$z'\ =\ h + \mathrm{RMSNorm}(\,\mathrm{SwiGLU}(h)\,)$", 9)
+txt(cx, 55.3, 'causal SDPA · QK-RMSNorm · RoPE', 7.5, GRAY)
+
+arrow(cx, 48.0, cx, 52.6)
+box(cx - 10, 44.0, 20, 4.0); txt(cx, 46.0, 'token embedding', 8.5)
+
+txt(16, 40.0, '42.75M (TinyStories 4k BPE) / 72.11M (FineWeb-Edu 32k BPE)', 7.8, GRAY)
+txt(16, 36.6, 'Nothing in this graph is modified for training: the trained checkpoint\n'
+ 'is indistinguishable in form from a conventionally trained model.', 8.2)
+
+# ======================== (B) free phase ========================
+bx = 47
+txt(bx, 102.3, '(B) Free phase (= inference)', 10.5, w='bold')
+txt(bx, 99.6, 'give each layer a state $z_l$ (3 of $L$ drawn); settle the disagreement energy', 8.5, GRAY)
+txt(bx, 95.9, r'$E(z)\ =\ \sum_l\ \frac{1}{2}\,\|\,z_l - f_l(z_{l-1})\,\|^2$', 10.5)
+
+def state_col(x0, ys, labels, notes, fc=BLUEF, ec=BLUE, note_c=GRAY):
+ for y, lab, note in zip(ys, labels, notes):
+ box(x0 - 9.5, y - 2.0, 19, 4.0, fc=fc, ec=ec, lw=1.1)
+ txt(x0, y, lab, 9, color=INK)
+ if note: txt(x0 + 11.0, y, note, 7.8, note_c, ha='left')
+ for ya, yb in zip(ys[1:], ys[:-1]):
+ arrow(x0, ya + 2.5, x0, yb - 2.6, lw=1.1)
+
+ys = [86, 74, 62]
+state_col(bx - 6, ys, [r'$z_3 = f_3(z_2)$', r'$z_2 = f_2(z_1)$', r'$z_1 = f_1(\mathrm{emb})$'],
+ ['term = 0', 'term = 0', 'term = 0'])
+arrow(bx - 6, 53.6, bx - 6, 59.0, lw=1.1)
+txt(bx - 6, 51.5, r'$\mathrm{emb}(x)$', 9)
+
+txt(bx, 44.6, 'The minimum is exact: $E=0$, states $\\equiv$ forward activations,', 8.4)
+txt(bx, 41.6, 'and one bottom-up pass reaches it.', 8.4)
+txt(bx, 37.6, 'The free phase adds nothing and changes nothing at inference.', 8.2, GRAY)
+
+# ======================== (C) nudged phase ========================
+nx = 81
+txt(nx, 102.3, '(C) Nudged phase (training only)', 10.5, w='bold')
+txt(nx, 99.6, r'add the loss at strength $\beta \ll 1$ and settle again:', 8.5, GRAY)
+txt(nx, 95.9, r'$E(z)\ +\ \beta\cdot\mathrm{CE}(\mathrm{logits}(z_3),\,y)$', 10.5)
+
+txt(nx + 2.0, 92.3, r'pull $-\beta\,\nabla\mathrm{CE}$', 8.6, RED, ha='left')
+txt(nx + 2.0, 90.0, '(the only place the label enters)', 7.3, RED, ha='left')
+arrow(nx + 1.0, 91.4, nx - 4.0, 88.6, color=RED, lw=1.5)
+
+nys = [86, 74, 62]
+state_col(nx - 6, nys,
+ [r'$z_3^{\beta} = z_3 + d_3$', r'$z_2^{\beta} = z_2 + d_2$', r'$z_1^{\beta} = z_1 + d_1$'],
+ [None, None, None], fc='#fdeee2', ec=ORAN)
+arrow(nx - 6, 53.6, nx - 6, 59.0, lw=1.1)
+txt(nx - 6, 51.5, r'$\mathrm{emb}(x)$', 9)
+
+for (ya, yb, lab) in [(84.0, 76.6, r'$d_2 = J_3^{\top} d_3$'),
+ (72.0, 64.6, r'$d_1 = J_2^{\top} d_2$')]:
+ arrow(nx + 5.4, ya, nx + 5.4, yb, color=ORAN, lw=1.5)
+ txt(nx + 7.0, (ya + yb) / 2, lab, 8.6, ORAN, ha='left')
+
+txt(nx, 44.6, 'The top state is pulled toward lower loss; each layer\'s mismatch $d_l$', 8.4)
+txt(nx, 41.6, 'transmits DOWN through the same weights, and the stack re-settles.', 8.4)
+txt(nx, 37.6, r'($J^{\top}$ = the transpose read a bidirectional physical device provides)', 8.2, GRAY)
+
+# ======================== (D) the update ========================
+box(2, 20.5, 96, 13.5, fc=CREAM, ec=CREME, lw=1.2)
+txt(4, 31.6, '(D) The update — a difference measurement between the two settled states', 10, w='bold', ha='left')
+txt(50, 27.9, r'$\hat{g}\ =\ \left[\ \partial_\theta E(z^{\beta})\ -\ \partial_\theta E(z^{0})\ \right]\,/\,\beta$'
+ r'$\qquad\qquad(\partial_\theta E(z^0)\equiv 0\ \mathrm{here,\ since}\ E=0)$', 10.5)
+txt(50, 24.5, r'per layer: $\Delta\theta_l\ \propto\ \langle\ d_l\ ,\ \partial f_l(z_{l-1})/\partial\theta_l\ \rangle\ /\ \beta$', 9.5)
+txt(50, 21.9, r'Each layer updates from its own boundary mismatch — no global backward graph, no global tape, no loss'
+ '\n'
+ r'derivatives except the top nudge. $\beta\to 0$ gives the exact gradient; bias is $O(\beta)$; a $\pm\beta$ two-sided read cancels it to $O(\beta^2)$.',
+ 8.2)
+
+# ======================== (E) not PC ========================
+box(2, 2.0, 96, 16.2, fc='#f7f7f7', ec=BOXE, lw=1.1)
+txt(4, 15.7, '(E) This is not predictive coding — same energy family, different measurement', 10, w='bold', ha='left')
+txt(4.5, 12.4, '· PC (as typically run): ONE settled phase with the target clamped hard; the update uses the raw errors of that single state → finite-clamp', 8.2, ha='left')
+txt(4.5, 10.1, ' bias. Its "exact-BP" results require freezing predictions during error transport (fixed-prediction) — backprop re-expressed in local variables.', 8.2, ha='left')
+txt(4.5, 7.5, '· EP (here): TWO phases and an infinitesimal nudge; the update is a difference quotient in β → bias is measured and controllable, and the', 8.2, ha='left')
+txt(4.5, 5.2, ' settle stays fully self-consistent — which is what physical hardware actually does.', 8.2, ha='left')
+txt(4.5, 3.0, '· The free phase is the zero-reference of the measurement: on analog hardware the subtraction cancels state-independent device offsets.', 8.2, ha='left')
+
+fig.savefig('/home/yurenh2/ept/assets/fig_primer_arch.pdf')
+fig.savefig('/home/yurenh2/ept/assets/fig_primer_arch.png', dpi=190)
+fig.savefig('/home/yurenh2/ept/assets/fig_primer_arch.svg')
+print('saved fig_primer_arch.{pdf,png,svg}')
diff --git a/ep_run/fig_primer_block.py b/ep_run/fig_primer_block.py
new file mode 100644
index 0000000..ba3b5d0
--- /dev/null
+++ b/ep_run/fig_primer_block.py
@@ -0,0 +1,148 @@
+"""Primer figure 2: one OLMo2 block at op granularity (companion to fig_primer_arch).
+Color semantics: weight-matmuls (crossbar-mappable) vs activation-activation matmuls
+vs norms vs elementwise. Vector PDF + PNG to ../assets/."""
+import matplotlib
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+plt.rcParams['svg.fonttype'] = 'none' # keep text editable in PPT
+from matplotlib.patches import FancyBboxPatch, Circle
+
+INK = '#3a3a3a'
+GRAY = '#8a8a8a'
+BOXF = '#f4f4f4'
+BOXE = '#9a9a9a'
+BLUE = '#2c6fbb'
+BLUEF = '#eaf1fa'
+PURP = '#7b5aa6'
+PURPF = '#f1ebf8'
+CREAM = '#faf6ee'
+CREME = '#c9b895'
+RAIL = '#555555'
+
+fig = plt.figure(figsize=(10.0, 14.2))
+ax = fig.add_axes([0, 0, 1, 1]); ax.set_xlim(0, 100); ax.set_ylim(0, 142); ax.axis('off')
+
+def box(xc, yc, w, s, fc=BOXF, ec=BOXE, size=8.6, lw=1.1, h=3.2, tc=INK):
+ b = FancyBboxPatch((xc - w / 2, yc - h / 2), w, h,
+ boxstyle='round,pad=0.25,rounding_size=0.55', fc=fc, ec=ec, lw=lw)
+ ax.add_patch(b)
+ ax.text(xc, yc, s, fontsize=size, color=tc, ha='center', va='center')
+
+def txt(x, y, s, size=8.5, color=INK, ha='center', va='center', w='normal', style='normal', rot=0):
+ ax.text(x, y, s, fontsize=size, color=color, ha=ha, va=va,
+ fontweight=w, fontstyle=style, rotation=rot)
+
+def arrow(x0, y0, x1, y1, color=INK, lw=1.15, ms=10):
+ ax.annotate('', xy=(x1, y1), xytext=(x0, y0),
+ arrowprops=dict(arrowstyle='-|>', color=color, lw=lw,
+ mutation_scale=ms, shrinkA=0.4, shrinkB=0.4))
+
+def rail(pts, color=RAIL, lw=1.4):
+ xs, ys = zip(*pts)
+ ax.plot(xs, ys, color=color, lw=lw, solid_capstyle='round', zorder=1)
+
+# ---------------- title ----------------
+txt(50, 138.8, 'One transformer block, op by op', 12.5, w='bold')
+txt(50, 135.9, 'OLMo2 ordering — RMSNorm after each sublayer, inside the residual.'
+ ' $C{=}512$ $H{=}8$ $h_d{=}64$ $T{=}256$ $h_{ff}{=}1408$', 8.8, GRAY)
+
+# ---------------- attention sublayer ----------------
+txt(3.2, 100, 'attention sublayer', 9.5, GRAY, style='italic', rot=90)
+
+txt(40, 131.6, r'input $z$ $(B,T,C)$', 9.5, w='bold')
+rail([(40, 130.4), (88, 130.4), (88, 71.8)]); arrow(88, 72.4, 42.0, 71.8, color=RAIL, lw=1.4)
+txt(90.2, 101, 'residual rail', 8, GRAY, rot=90)
+
+arrow(40, 130.2, 40, 128.6)
+box(40, 126.9, 30, r'matmul $W_{qkv}: C \to 3C$', fc=BLUEF, ec=BLUE)
+txt(56.5, 126.9, r'$(B,T,3C)$ — split into $q,k,v$', 7.6, GRAY, ha='left')
+
+qx, kx, vx = 16, 40, 64
+for x, s in [(qx, '$q$'), (kx, '$k$'), (vx, '$v$')]:
+ txt(x, 123.3, s, 9.5, w='bold')
+arrow(37, 125.3, qx + 1, 122.5); arrow(40, 125.3, kx, 122.5); arrow(43, 125.3, vx - 1, 122.5)
+
+box(qx, 120.9, 17.5, r'RMSNorm$_q$', fc=CREAM, ec=CREME)
+box(kx, 120.9, 17.5, r'RMSNorm$_k$', fc=CREAM, ec=CREME)
+box(qx, 116.2, 17.5, 'split heads ($H{=}8$)')
+box(kx, 116.2, 17.5, 'split heads ($H{=}8$)')
+box(vx, 116.2, 17.5, 'split heads ($H{=}8$)')
+box(qx, 111.5, 17.5, 'RoPE')
+box(kx, 111.5, 17.5, 'RoPE')
+txt(50.2, 111.5, r'$\theta = 5{\cdot}10^5$', 7.6, GRAY, ha='left')
+for x in (qx, kx):
+ arrow(x, 119.3, x, 117.8); arrow(x, 114.6, x, 113.1)
+arrow(vx, 121.9, vx, 117.8)
+
+arrow(qx, 109.9, 30, 107.2); arrow(kx, 109.9, 40, 107.2); arrow(vx, 114.6, 50, 107.2)
+box(40, 105.5, 32, r'matmul $S = q\,k^{\top} \cdot 1/\sqrt{h_d}$', fc=PURPF, ec=PURP)
+txt(57.5, 105.5, r'$(B,H,T,T)$', 7.6, GRAY, ha='left')
+arrow(40, 103.9, 40, 102.4)
+box(40, 100.8, 32, 'causal mask')
+arrow(40, 99.2, 40, 97.7)
+box(40, 96.1, 32, 'softmax over keys')
+arrow(40, 94.5, 40, 93.0)
+box(40, 91.4, 32, r'matmul $y = A\,v$', fc=PURPF, ec=PURP)
+txt(57.5, 91.4, r'$(B,H,T,h_d)$', 7.6, GRAY, ha='left')
+arrow(40, 89.8, 40, 88.3)
+box(40, 86.7, 32, 'merge heads')
+txt(57.5, 86.7, r'$(B,T,C)$', 7.6, GRAY, ha='left')
+arrow(40, 85.1, 40, 83.6)
+box(40, 82.0, 32, r'matmul $W_{proj}: C \to C$', fc=BLUEF, ec=BLUE)
+arrow(40, 80.4, 40, 78.9)
+box(40, 77.3, 32, r'RMSNorm$_{attn}$', fc=CREAM, ec=CREME)
+txt(57.5, 77.3, '◄ OLMo2: norm on the sublayer OUTPUT,\n not on its input', 7.6, INK, ha='left')
+arrow(40, 75.7, 40, 73.8)
+c1 = Circle((40, 71.8), 1.9, fc='white', ec=INK, lw=1.3, zorder=3); ax.add_patch(c1)
+txt(40, 71.8, '+', 11, w='bold')
+
+arrow(40, 69.9, 40, 67.3)
+txt(42.2, 68.7, r"$z'$", 10, w='bold', ha='left')
+
+# ---------------- SwiGLU sublayer ----------------
+txt(3.2, 50, 'SwiGLU sublayer', 9.5, GRAY, style='italic', rot=90)
+
+rail([(40, 67.0), (88, 67.0), (88, 37.2)]); arrow(88, 37.8, 42.0, 37.2, color=RAIL, lw=1.4)
+
+arrow(38, 66.4, 27, 64.0); arrow(42, 66.4, 53, 64.0)
+box(26, 62.4, 21, r'matmul $W_1: C \to h_{ff}$', fc=BLUEF, ec=BLUE)
+box(54, 62.4, 21, r'matmul $W_3: C \to h_{ff}$', fc=BLUEF, ec=BLUE)
+txt(65.7, 62.4, r'$h_{ff} = 1408$', 7.6, GRAY, ha='left')
+arrow(26, 60.8, 26, 59.3)
+box(26, 57.7, 12, 'SiLU')
+arrow(26, 56.1, 33.5, 54.1); arrow(54, 60.8, 46.5, 54.1)
+box(40, 52.4, 23, '⊙ elementwise gate')
+arrow(40, 50.8, 40, 49.3)
+box(40, 47.1, 25, r'matmul $W_2: h_{ff} \to C$', fc=BLUEF, ec=BLUE)
+arrow(40, 45.5, 40, 44.0)
+box(40, 42.4, 25, r'RMSNorm$_{ff}$', fc=CREAM, ec=CREME)
+txt(54, 42.4, '◄ norm after the sublayer, again', 7.6, INK, ha='left')
+arrow(40, 40.8, 40, 39.2)
+c2 = Circle((40, 37.2), 1.9, fc='white', ec=INK, lw=1.3, zorder=3); ax.add_patch(c2)
+txt(40, 37.2, '+', 11, w='bold')
+arrow(40, 35.3, 40, 32.8)
+txt(40, 31.3, r'output $z^{\prime\prime}$ — one full $f_l$ done', 9.5, w='bold')
+
+# ---------------- legend + notes ----------------
+def chip(x, y, fc, ec):
+ b = FancyBboxPatch((x, y - 0.9), 3.4, 1.8, boxstyle='round,pad=0.15,rounding_size=0.35',
+ fc=fc, ec=ec, lw=1.1)
+ ax.add_patch(b)
+
+chip(5, 25.5, BLUEF, BLUE)
+txt(9.5, 25.5, 'weight × activation matmul — 7 logical ($W_{qkv}$ fuses $W_q,W_k,W_v$); crossbar-mappable on analog hardware', 8.2, ha='left')
+chip(5, 22.1, PURPF, PURP)
+txt(9.5, 22.1, r'activation × activation matmul — 2 ($q\,k^{\top}$ and $A\,v$); computed on the fly: the non-crossbar part of attention', 8.2, ha='left')
+chip(5, 18.7, CREAM, CREME)
+txt(9.5, 18.7, 'RMSNorm (learned gain)', 8.2, ha='left')
+chip(38, 18.7, BOXF, BOXE)
+txt(42.5, 18.7, 'elementwise / reshape', 8.2, ha='left')
+
+txt(5, 14.6, 'No biases anywhere in the network. The $1/\\sqrt{h_d}$ scale is folded into $S$; mask and softmax run along the key axis.', 8.2, ha='left')
+txt(5, 11.9, r'$h_{ff} = 1408 \approx 8C/3$, rounded up to a multiple of 64. QK-norm acts on the full width $C$, before the head split.', 8.2, ha='left')
+txt(5, 9.2, r'The boxed pipeline, input $z$ to output $z^{\prime\prime}$, is exactly $f_l$ on the companion page; the state $z_l$ lives on the residual rail.', 8.2, ha='left')
+
+fig.savefig('/home/yurenh2/ept/assets/fig_primer_block.pdf')
+fig.savefig('/home/yurenh2/ept/assets/fig_primer_block.png', dpi=185)
+fig.savefig('/home/yurenh2/ept/assets/fig_primer_block.svg')
+print('saved fig_primer_block.{pdf,png,svg}')
diff --git a/ep_run/fig_primer_pack2.py b/ep_run/fig_primer_pack2.py
new file mode 100644
index 0000000..ff28476
--- /dev/null
+++ b/ep_run/fig_primer_pack2.py
@@ -0,0 +1,201 @@
+"""Primer deck figures: schedule, 72M blowup (real logs), loop-gain, beta window.
+PNGs to ../assets/ for the pptx build."""
+import re
+import matplotlib
+matplotlib.use('Agg')
+import numpy as np
+import matplotlib.pyplot as plt
+from matplotlib.patches import FancyBboxPatch
+
+INK, GRAY = '#3a3a3a', '#8a8a8a'
+BOXF, BOXE = '#f4f4f4', '#9a9a9a'
+BLUE, BLUEF = '#2c6fbb', '#eaf1fa'
+ORAN, ORANF = '#d95f02', '#fdeee2'
+RED = '#b03a2e'
+CREAM, CREME = '#faf6ee', '#c9b895'
+GREEN = '#2e7d32'
+A = '/home/yurenh2/ept/assets/'
+
+def newfig(w, h, xmax=100, ymax=62):
+ fig = plt.figure(figsize=(w, h))
+ ax = fig.add_axes([0, 0, 1, 1]); ax.set_xlim(0, xmax); ax.set_ylim(0, ymax); ax.axis('off')
+ return fig, ax
+
+def box(ax, xc, yc, w, s, fc=BOXF, ec=BOXE, size=10, lw=1.2, h=4.6, tc=INK, wt='normal'):
+ ax.add_patch(FancyBboxPatch((xc - w / 2, yc - h / 2), w, h,
+ boxstyle='round,pad=0.3,rounding_size=0.7', fc=fc, ec=ec, lw=lw))
+ ax.text(xc, yc, s, fontsize=size, color=tc, ha='center', va='center', fontweight=wt)
+
+def txt(ax, x, y, s, size=9.5, color=INK, ha='center', va='center', w='normal', style='normal'):
+ ax.text(x, y, s, fontsize=size, color=color, ha=ha, va=va, fontweight=w, fontstyle=style)
+
+def arrow(ax, x0, y0, x1, y1, color=INK, lw=1.4, ms=12, cs=None):
+ ax.annotate('', xy=(x1, y1), xytext=(x0, y0),
+ arrowprops=dict(arrowstyle='-|>', color=color, lw=lw, mutation_scale=ms,
+ shrinkA=0.5, shrinkB=0.5, connectionstyle=cs))
+
+# ================= fig S3: the update schedule =================
+fig, ax = newfig(11.8, 6.0)
+dx, zx = 15, 41
+txt(ax, dx, 58.5, 'mismatches derive TOP → BOTTOM', 10, ORAN, w='bold')
+txt(ax, zx, 58.5, 'states rebuild BOTTOM → TOP', 10, BLUE, w='bold')
+dl = [r'$d_3 = -\beta\,\nabla\mathrm{CE}$', r'$d_2 = J_3^{\top} d_3$', r'$d_1 = J_2^{\top} d_2$']
+dn = ['needs: loss only', 'needs: block 3 only', 'needs: block 2 only']
+for i, (s, n) in enumerate(zip(dl, dn)):
+ y = 51 - i * 9
+ box(ax, dx, y, 20, s, fc=ORANF, ec=ORAN)
+ txt(ax, dx, y - 3.4, n, 7.6, GRAY)
+ if i < 2: arrow(ax, dx, y - 2.4 - 2.2, dx, y - 9 + 2.5, color=ORAN)
+zl = [r'$z_1 = f_1(\mathrm{emb}) + d_1$', r'$z_2 = f_2(z_1) + d_2$', r'$z_3 = f_3(z_2) + d_3$']
+for i, s in enumerate(zl):
+ y = 33 + i * 9
+ box(ax, zx, y, 20, s, fc=BLUEF, ec=BLUE)
+ if i < 2: arrow(ax, zx, y + 2.4, zx, y + 9 - 2.5, color=BLUE)
+txt(ax, 28, 22.5, 'block $l$ touches NEIGHBORS only — $d$ from the block after it,\n'
+ '$z$ from the block before it. $\\Delta\\theta_l$ fires as soon as $d_l$ arrives.\n'
+ 'Repeat $K$ times ($K{=}3$ measured sufficient; $K{=}8$ identical).', 9)
+
+sx = 76
+txt(ax, sx, 58.5, 'the same settle, three schedules', 10.5, INK, w='bold')
+rows = [
+ ('sequential (our trainer today)', r'$O(K\!\cdot\!d)$', '3 × 12 = 36 block-ops', BOXF, BOXE),
+ ('pipelined wavefront (systolic; multi-GPU lever)', r'$O(K\!+\!d\!-\!1)$', '3 + 12 − 1 = 14 slots', BLUEF, BLUE),
+ ('continuous-time analog: all blocks relax AT ONCE', r'$\sim O(K)$', 'depth-serial factor gone;\n'
+ r'$K$ dissolves into settling time $\tau$', ORANF, ORAN),
+]
+for i, (name, comp, det, fc, ec) in enumerate(rows):
+ y = 50 - i * 12.5
+ box(ax, sx, y, 44, '', fc=fc, ec=ec, h=9.5)
+ txt(ax, sx - 20.5, y + 2.2, name, 9.3, ha='left', w='bold')
+ txt(ax, sx - 20.5, y - 1.8, det, 8.6, GRAY, ha='left')
+ txt(ax, sx + 15.5, y + 2.2, comp, 12)
+txt(ax, sx, 9.5, 'open question we track: does $\\tau$ itself grow with depth?\n'
+ '(one transit down the chain is unavoidable)', 8.4, GRAY)
+fig.savefig(A + 'fig_p3_schedule.png', dpi=170); plt.close(fig)
+
+# ================= fig S4: what happened at 72M (real logs) =================
+STEP_RE = re.compile(r'^step\s+(\d+)/\d+ \| train [\d.]+ val ([\d.]+) \(best ([\d.]+)\).*?beta=([\d.e+-]+)')
+def parse(fn):
+ out = {}
+ for line in open('/home/yurenh2/ept/ep_run/runs/' + fn, errors='replace'):
+ m = STEP_RE.match(line)
+ if m: out[int(m.group(1))] = (float(m.group(2)), float(m.group(4)))
+ return out
+
+base = parse('fw72m.log')
+resc = parse('fw72m_c.log'); resc.update(parse('fw72m_c2.log'))
+bs = np.array(sorted(base)); bval = np.array([base[s][0] for s in bs])
+rs = np.array(sorted(k for k in resc if k >= 195000)); rval = np.array([resc[s][0] for s in rs])
+print('base max val post-195k:', bval[bs > 195000].max(), '| resc pts:', len(rs))
+beta_steps = np.array(sorted([k for k in base if k < 195000] + list(rs)))
+beta_v = np.array([base[k][1] if k < 195000 else resc[k][1] for k in beta_steps])
+
+fig = plt.figure(figsize=(9.2, 6.0))
+axv = fig.add_axes([0.085, 0.12, 0.815, 0.78])
+axv.plot(bs / 1000, np.clip(bval, 0, 6.42), color=BLUE, lw=1.0, zorder=3)
+axv.plot(rs / 1000, np.clip(rval, 0, 6.42), color='#999999', lw=0.9, zorder=2)
+axv.text(233, 4.32, 'rescue resumes\n(new recipe from 195k / 215k)', fontsize=8, color='#777777', ha='right')
+axv.axhline(3.2884, color=GRAY, lw=1.0, ls='--')
+axv.text(2, 3.19, 'BP twin final 3.2884', fontsize=8.5, color=GRAY, va='top')
+axv.plot([185], [3.7117], marker='*', ms=14, color=GREEN, zorder=5)
+axv.text(180, 3.50, 'best 3.7117 @185k (never beaten again)', fontsize=8.5, color=GREEN, ha='right')
+axv.annotate('195k: relaxation diverges — val 3.9 → 6.0,\nguard storm (15k step-skips); the same\n$\\beta$ was safe for the previous 135k steps',
+ xy=(204, 5.95), xytext=(112, 5.62), fontsize=9.5, color=RED,
+ arrowprops=dict(arrowstyle='-|>', color=RED))
+axv.axvline(60, color=GRAY, lw=0.8, ls=':')
+axv.axvline(215, color=GRAY, lw=0.8, ls=':')
+axv.text(61, 6.30, 'floor raised\n3e-4 → 1e-3: fine', fontsize=8, color=GRAY, va='top')
+axv.text(146, 4.78, 'governor caps $\\beta$: survives,\nbut starved to 2e-5 — best frozen', fontsize=8.5, color=INK)
+axv.set_xlabel('step (×1000)', fontsize=10); axv.set_ylabel('val CE', fontsize=10, color=BLUE)
+axv.set_ylim(3.0, 6.5); axv.set_xlim(0, 238)
+axv.tick_params(labelsize=9)
+axb = axv.twinx()
+axb.plot(beta_steps / 1000, beta_v, color=ORAN, lw=1.3, alpha=0.9, drawstyle='steps-post')
+axb.set_yscale('log'); axb.set_ylim(8e-6, 6e-3)
+axb.set_ylabel(r'$\beta$ (log)', fontsize=10, color=ORAN)
+axb.tick_params(labelsize=8, colors=ORAN)
+for sp in ['top']: axv.spines[sp].set_visible(False); axb.spines[sp].set_visible(False)
+axv.set_title('fw72m — 72.11M params × 1.44B FineWeb tokens, fully BP-free (what actually happened)',
+ fontsize=11, color=INK, pad=10)
+fig.savefig(A + 'fig_p4_blowup.png', dpi=170); plt.close(fig)
+
+# ================= fig S5: the loop =================
+fig, ax = newfig(8.8, 6.2, xmax=100, ymax=70)
+bx = 26
+for i, name in enumerate(['block 1', 'block 2', 'block 3']):
+ box(ax, bx, 16 + i * 15, 22, name, fc=BOXF, ec=BOXE, h=8, size=11)
+arrow(ax, bx + 12, 20, bx + 12, 27, color=BLUE, lw=2.0); arrow(ax, bx + 12, 35, bx + 12, 42, color=BLUE, lw=2.0)
+arrow(ax, bx - 12, 42, bx - 12, 35, color=ORAN, lw=2.0); arrow(ax, bx - 12, 27, bx - 12, 20, color=ORAN, lw=2.0)
+ax.text(bx - 17.5, 31, 'errors flow DOWN $d_l = J_{l+1}^{\\top} d_{l+1}$', fontsize=9, color=ORAN,
+ ha='center', va='center', rotation=90)
+ax.text(bx + 17.5, 31, 'states rebuild UP $z_l = f_l(z_{l-1}) + d_l$', fontsize=9, color=BLUE,
+ ha='center', va='center', rotation=270)
+arrow(ax, bx + 9, 51.5, bx - 9, 51.5, color=RED, lw=2.0, cs='arc3,rad=0.45')
+txt(ax, bx, 60, 'top link: $-\\beta\\,\\nabla\\mathrm{CE}$, scale $\\sigma(W_{out})^2$', 9.5, RED)
+txt(ax, bx, 6, 'one sweep = down + up:\na closed loop through the stack', 9.5, GRAY)
+
+L = 50
+box(ax, 72.5, 56, 45, r'per-sweep gain $\rho \;\approx\; \beta\cdot\sigma(W_{out})^2\cdot\|J\mathrm{-chains}\|^2$',
+ fc=CREAM, ec=CREME, h=8, size=10.5)
+txt(ax, L, 46, r'$\rho < 1$: mismatch decays every sweep → settles', 10, GREEN, ha='left')
+txt(ax, L, 41, r'$\rho > 1$: every sweep AMPLIFIES the residual → diverges', 10, RED, ha='left')
+txt(ax, L, 34.8, 'and $\\rho$ RISES during training:', 10, INK, ha='left')
+txt(ax, L, 31.3, r'$\sigma(W_{out})$ and $\|J\|$ grow as the fit deepens', 10, INK, ha='left')
+txt(ax, L, 24, 'measured:', 9.5, INK, ha='left', w='bold')
+txt(ax, L, 20.3, '· damping refuted in 3 doses (spectrum monotone-positive)', 9, INK, ha='left')
+txt(ax, L, 16.8, '· lowering $\\beta$ restores convergence instantly', 9, INK, ha='left')
+txt(ax, L, 13.3, '· continuous-time flow is unconditionally stable →', 9, INK, ha='left')
+txt(ax, L + 1.5, 9.6, 'a DISCRETE-SOLVER disease; analog hardware\nhas no such ceiling', 9, ORAN, ha='left')
+fig.savefig(A + 'fig_p5_loop.png', dpi=170); plt.close(fig)
+
+# ================= fig S6: the beta window =================
+fig = plt.figure(figsize=(9.2, 6.0))
+ax2 = fig.add_axes([0.10, 0.13, 0.86, 0.76])
+x = np.linspace(0, 234, 500)
+cx = [0, 60, 120, 160, 195, 215, 222, 230, 234]
+cy = [1.2e-2, 1.0e-2, 6e-3, 2.5e-3, 9.5e-4, 4e-4, 2.6e-4, 4e-5, 2.2e-5]
+ceil = np.exp(np.interp(x, cx, np.log(cy)))
+fx_ = [0, 60, 120, 180, 234]; fy = [8e-5, 1.1e-4, 1.6e-4, 2.6e-4, 4.5e-4]
+floor = np.exp(np.interp(x, fx_, np.log(fy)))
+ax2.fill_between(x, ceil, 2e-2, color=RED, alpha=0.10)
+ax2.fill_between(x, 8e-6, floor, color='#666666', alpha=0.12)
+ax2.fill_between(x, floor, ceil, color=GREEN, alpha=0.07)
+ax2.plot(x, ceil, color=RED, lw=1.6)
+ax2.plot(x, floor, color='#555555', lw=1.6)
+ax2.text(6, 6.5e-3, r'wall-2: $\rho>1$ — relaxation diverges', fontsize=10, color=RED)
+ax2.text(6, 2.6e-5, 'wall-1: SNR < 1 — updates dissolve into noise', fontsize=10, color='#555555')
+ax2.text(96, 1.35e-3, r'$\beta_{max}(t) \propto \mathrm{margin}/(\sigma^2\|J\|^2)$', fontsize=9.5, color=RED, rotation=-14)
+ax2.text(120, 1.05e-4, r'$\beta_{min}(t) \propto \mathrm{noise}/|g|$', fontsize=9.5, color='#555555', rotation=6)
+ax2.text(150, 5.5e-4, 'operating window\n(narrows as fit deepens)', fontsize=9.5, color=GREEN, ha='center')
+# original schedule
+ax2.plot([0, 60], [3e-4, 3e-4], color=INK, lw=2.2)
+ax2.plot([60, 60], [3e-4, 1e-3], color=INK, lw=1.0, ls=':')
+ax2.plot([60, 195], [1e-3, 1e-3], color=INK, lw=2.2)
+ax2.plot([195], [1e-3], marker='x', ms=13, mew=3, color=RED, zorder6b=5) if False else ax2.plot([195], [1e-3], marker='x', ms=13, mew=3, color=RED, zorder=5)
+ax2.text(196.5, 1.25e-3, 'fixed floor crosses the\nfalling ceiling → blow', fontsize=8.5, color=RED)
+ax2.text(90, 7.6e-4, 'fw72m schedule (fixed floors)', fontsize=8.5, color=INK)
+# bcap rescue
+bs = x[(x >= 215) & (x <= 234)]
+ax2.plot(bs, np.exp(np.interp(bs, [215, 222, 230, 234], np.log([3e-4, 2.2e-4, 3.5e-5, 2e-5]))),
+ color=GRAY, lw=1.8, ls='-.')
+ax2.text(213, 4.8e-5, 'bcap rescue: survives, starved —\nthe window had CLOSED for the\nold recipe\'s margin', fontsize=8, color=GRAY, ha='right')
+# cent path
+xm = x[x <= 185]
+cent = np.where(xm < 20, 3e-4, 3e-3)
+cent = np.minimum(cent, np.exp(np.interp(xm, cx, np.log(cy))) * 0.85)
+ax2.plot(xm, cent, color=ORAN, lw=2.2, ls='--')
+ax2.text(24, 4.25e-3, 'fw72m_cent (in flight): centered + $\\beta$ rides ITS OWN ceiling via bcap 0.9 — where that ceiling sits is being measured now (48k: ahead)', fontsize=8.6, color=ORAN, ha='left')
+ax2.text(186.5, 1.62e-3, '→ ?', fontsize=10, color=ORAN, fontweight='bold')
+# measured ceiling points
+for (mx, my, lab) in [(195, 1e-3, '≤1e-3'), (222, 3e-4, '≤3e-4'), (228, 2.5e-5, '≈2e-5')]:
+ ax2.plot([mx], [my], marker='v', ms=7, color=RED, zorder=5)
+ax2.text(231, 2.3e-3, 'measured ceiling\ncrossings ▾', fontsize=8, color=RED, ha='right')
+ax2.set_yscale('log'); ax2.set_ylim(8e-6, 2e-2); ax2.set_xlim(0, 236)
+ax2.set_xlabel('training progress (×1000 steps)', fontsize=10)
+ax2.set_ylabel(r'$\beta$ (log scale)', fontsize=10)
+ax2.tick_params(labelsize=9)
+for sp in ['top', 'right']: ax2.spines[sp].set_visible(False)
+ax2.set_title(r'$\beta$ is squeezed from both sides — and the window narrows as the model fits deeper',
+ fontsize=11.5, color=INK, pad=10)
+fig.savefig(A + 'fig_p6_window.png', dpi=170); plt.close(fig)
+print('figs saved: p3_schedule, p4_blowup, p5_loop, p6_window')
diff --git a/ep_run/fig_primer_phases.py b/ep_run/fig_primer_phases.py
new file mode 100644
index 0000000..b04ed5f
--- /dev/null
+++ b/ep_run/fig_primer_phases.py
@@ -0,0 +1,99 @@
+"""Slide variant of fig_primer_arch: free/nudged phases + update only (no model panel,
+no PC contrast). PDF/PNG/SVG to ../assets/."""
+import matplotlib
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+plt.rcParams['svg.fonttype'] = 'none' # keep text editable in PPT
+from matplotlib.patches import FancyBboxPatch
+
+INK = '#3a3a3a'
+GRAY = '#8a8a8a'
+BOXF = '#f4f4f4'
+BOXE = '#9a9a9a'
+BLUE = '#2c6fbb'
+BLUEF = '#eaf1fa'
+ORAN = '#d95f02'
+RED = '#b03a2e'
+CREAM = '#faf6ee'
+CREME = '#c9b895'
+
+fig = plt.figure(figsize=(12.2, 11.0))
+ax = fig.add_axes([0, 0, 1, 1]); ax.set_xlim(0, 100); ax.set_ylim(16, 105); ax.axis('off')
+
+def box(x, y, w, h, fc=BOXF, ec=BOXE, lw=1.0, r=0.6):
+ b = FancyBboxPatch((x, y), w, h, boxstyle=f'round,pad=0.25,rounding_size={r}',
+ fc=fc, ec=ec, lw=lw)
+ ax.add_patch(b); return b
+
+def txt(x, y, s, size=8.5, color=INK, ha='center', va='center', w='normal', style='normal'):
+ ax.text(x, y, s, fontsize=size, color=color, ha=ha, va=va,
+ fontweight=w, fontstyle=style)
+
+def arrow(x0, y0, x1, y1, color=INK, lw=1.2, style='-|>', ms=11):
+ ax.annotate('', xy=(x1, y1), xytext=(x0, y0),
+ arrowprops=dict(arrowstyle=style, color=color, lw=lw,
+ mutation_scale=ms, shrinkA=0.5, shrinkB=0.5))
+
+def state_col(x0, ys, labels, notes, fc=BLUEF, ec=BLUE, note_c=GRAY):
+ for y, lab, note in zip(ys, labels, notes):
+ box(x0 - 10.5, y - 2.2, 21, 4.4, fc=fc, ec=ec, lw=1.2)
+ txt(x0, y, lab, 10, color=INK)
+ if note: txt(x0 + 12.2, y, note, 8.4, note_c, ha='left')
+ for ya, yb in zip(ys[1:], ys[:-1]):
+ arrow(x0, ya + 2.7, x0, yb - 2.8, lw=1.15)
+
+# ======================== free phase ========================
+bx = 25
+txt(bx, 102.3, 'Free phase (= inference)', 11.5, w='bold')
+txt(bx, 99.4, 'give each layer a state $z_l$ (3 of $L$ drawn); settle the disagreement energy', 8.8, GRAY)
+txt(bx, 95.4, r'$E(z)\ =\ \sum_l\ \frac{1}{2}\,\|\,z_l - f_l(z_{l-1})\,\|^2$', 11.5)
+
+ys = [85, 73, 61]
+state_col(bx - 6, ys, [r'$z_3 = f_3(z_2)$', r'$z_2 = f_2(z_1)$', r'$z_1 = f_1(\mathrm{emb})$'],
+ ['term = 0', 'term = 0', 'term = 0'])
+arrow(bx - 6, 52.4, bx - 6, 58.0, lw=1.15)
+txt(bx - 6, 50.2, r'$\mathrm{emb}(x)$', 10)
+
+txt(bx, 43.8, 'The minimum is exact: $E=0$, states $\\equiv$ forward activations,', 9)
+txt(bx, 40.8, 'and one bottom-up pass reaches it.', 9)
+txt(bx, 37.2, 'The free phase adds nothing and changes nothing at inference.', 8.6, GRAY)
+
+# ======================== nudged phase ========================
+nx = 70
+txt(nx, 102.3, 'Nudged phase (training only)', 11.5, w='bold')
+txt(nx, 99.4, r'add the loss at strength $\beta \ll 1$ and settle again:', 8.8, GRAY)
+txt(nx, 95.4, r'$E(z)\ +\ \beta\cdot\mathrm{CE}(\mathrm{logits}(z_3),\,y)$', 11.5)
+
+txt(nx + 3.0, 91.8, r'pull $-\beta\,\nabla\mathrm{CE}$', 9.4, RED, ha='left')
+txt(nx + 3.0, 89.4, '(the only place the label enters)', 7.8, RED, ha='left')
+arrow(nx + 2.0, 90.9, nx - 3.5, 87.8, color=RED, lw=1.6)
+
+nys = [85, 73, 61]
+state_col(nx - 6, nys,
+ [r'$z_3^{\beta} = z_3 + d_3$', r'$z_2^{\beta} = z_2 + d_2$', r'$z_1^{\beta} = z_1 + d_1$'],
+ [None, None, None], fc='#fdeee2', ec=ORAN)
+arrow(nx - 6, 52.4, nx - 6, 58.0, lw=1.15)
+txt(nx - 6, 50.2, r'$\mathrm{emb}(x)$', 10)
+
+for (ya, yb, lab) in [(83.0, 75.8, r'$d_2 = J_3^{\top} d_3$'),
+ (71.0, 63.8, r'$d_1 = J_2^{\top} d_2$')]:
+ arrow(nx + 6.4, ya, nx + 6.4, yb, color=ORAN, lw=1.6)
+ txt(nx + 8.2, (ya + yb) / 2, lab, 9.4, ORAN, ha='left')
+
+txt(nx, 43.8, 'The top state is pulled toward lower loss; each layer\'s mismatch $d_l$', 9)
+txt(nx, 40.8, 'transmits DOWN through the same weights, and the stack re-settles.', 9)
+txt(nx, 37.2, r'($J^{\top}$ = the transpose read a bidirectional physical device provides)', 8.6, GRAY)
+
+# ======================== the update ========================
+box(2, 18.5, 96, 15.0, fc=CREAM, ec=CREME, lw=1.2)
+txt(4, 30.9, 'The update — a difference measurement between the two settled states', 11, w='bold', ha='left')
+txt(50, 27.1, r'$\hat{g}\ =\ \left[\ \partial_\theta E(z^{\beta})\ -\ \partial_\theta E(z^{0})\ \right]\,/\,\beta$'
+ r'$\qquad\qquad(\partial_\theta E(z^0)\equiv 0\ \mathrm{here,\ since}\ E=0)$', 11)
+txt(50, 23.4, r'per layer: $\Delta\theta_l\ \propto\ \langle\ d_l\ ,\ \partial f_l(z_{l-1})/\partial\theta_l\ \rangle\ /\ \beta$', 10)
+txt(50, 20.4, r'Each layer updates from its own boundary mismatch — no global backward graph, no global tape, no loss'
+ r' derivatives except the top nudge.', 8.6)
+
+fig.savefig('/home/yurenh2/ept/assets/fig_primer_phases.pdf')
+fig.savefig('/home/yurenh2/ept/assets/fig_primer_phases.png', dpi=185)
+fig.savefig('/home/yurenh2/ept/assets/fig_primer_phases.svg')
+print('saved fig_primer_phases.{pdf,png,svg}')