Skip to content

Commit 9188dc7

Browse files
tacioclaude
andcommitted
S3 Increment 2: fan out the visual surface (learn-loop + rollout GIFs, MAP-Elites illumination)
Reuse Increment 1's Mojo-trace -> dependency-free renderer -> committed-asset pipeline for the three artifacts named in the S3 plan. Everything stays numpy + stdlib (no Pillow): a self-contained GIF89a/LZW encoder in tools/viz/gifpng.py, validated by a full encode->decode round-trip over every frame. - "Watch it learn" (flip_h): src/main.mojo gains --learn-trace; the capture is a viz-only twin forward_with_learning_traced in esper_evolution.mojo that mirrors fit_operator's annealed schedule exactly and snapshots the held-out prediction into a pre-allocated buffer BETWEEN ES steps (never in the hot loop). Renderer: tools/viz/render_learn.py (GIF + filmstrip). - Repertoire rollout: src/sandbox_trace.mojo loads a .rep, picks the highest-occupancy elite, and rolls it out via a new viz-only sandbox_rollout_trace twin in sandbox.mojo that snapshots the grid per tick; the ES-critical rollouts are untouched. Renderer: tools/viz/render_rollout.py. - MAP-Elites illumination (zero Mojo change): tools/viz/render_repertoire.py reads the existing .rep binary directly (avatar-position coverage + paint field). Production forward_with_learning / fit_operator / ES rollouts are byte-untouched; the additions are strictly additive/flag-gated. ./esper fast stays green (3/3 held-out). README gains a "Watch it work" section + reproduce blocks; JOURNAL records the entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9e600f0 commit 9188dc7

15 files changed

Lines changed: 866 additions & 3 deletions

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ Under the hood is **Nested Learning** (the HOPE architecture):
2929
The engine learns the transformation; it is never handed a symbolic DSL. Geometric,
3030
colour, and count transforms *emerge* as fitted operator parameters.
3131

32+
## Watch it work
33+
34+
**Learning a transform in-context.** The operator starts at the identity prior and
35+
is fitted by the ES on the demo pairs. Below is its prediction on the **held-out**
36+
input converging over 4 000 iterations — from the identity, through noise, to an
37+
exact `flip_h`:
38+
39+
![Esper's held-out prediction converging from identity to an exact horizontal flip over ES iterations.](docs/assets/learn.gif)
40+
41+
**A learned open-ended behaviour.** Esper's reward-free MAP-Elites repertoire
42+
illuminates a space of sandbox behaviours through the *unchanged* ES core. One
43+
elite's policy, rolled out under the sandbox's gravity, paints the world floor-up:
44+
45+
![A repertoire policy painting the sandbox world, its paint settling under gravity over 64 ticks.](docs/assets/rollout.gif)
46+
47+
…and the repertoire's coverage of that behaviour space — the avatar-final-position
48+
illumination map (left) and the mean paint field (right):
49+
50+
![MAP-Elites illumination: coverage of avatar-position behaviour space, and the repertoire's mean paint field.](docs/assets/repertoire.png)
51+
52+
Every frame is rendered offline from a `--trace` / `.rep` dump by a dependency-free
53+
renderer (numpy + stdlib only) — nothing is hand-drawn. See [Reproduce](#reproduce).
54+
3255
## Results (honest, including the negatives)
3356

3457
The evidence trail is the currency here. Everything is fit on train pairs and scored
@@ -90,6 +113,22 @@ python tools/viz/render_hero.py /tmp/trace.jsonl --out docs/assets/hero.png
90113
pred, true, …}`) *after* each fit — never inside the ES hot loop. The renderer is
91114
dependency-free (numpy + stdlib `zlib` only).
92115

116+
**The "watch it learn" animation** (per-K-iter held-out prediction → GIF + filmstrip):
117+
```bash
118+
./esper run src/main.mojo --learn-trace /tmp/learn.jsonl
119+
python tools/viz/render_learn.py /tmp/learn.jsonl # -> docs/assets/learn.{gif,png}
120+
```
121+
122+
**A repertoire rollout + illumination map** (needs a `.rep` under `build/`, produced
123+
by the repertoire tests or a MAP-Elites run — `build/` is gitignored):
124+
```bash
125+
./esper run src/sandbox_trace.mojo build/b_poc_4.rep /tmp/rollout.jsonl
126+
python tools/viz/render_rollout.py /tmp/rollout.jsonl # -> docs/assets/rollout.{gif,png}
127+
python tools/viz/render_repertoire.py build/b_poc_4.rep # -> docs/assets/repertoire.png
128+
```
129+
The animated GIFs come from a self-contained GIF89a/LZW encoder in
130+
`tools/viz/gifpng.py` — still numpy + stdlib only, no Pillow.
131+
93132
**The interactive triptych** (marimo + anywidget, decoupled from the engine):
94133
```bash
95134
uv pip install marimo anywidget # viz-only deps; the engine needs none of them

docs/JOURNAL.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4045,3 +4045,38 @@ heavy to commit. Resolved by committing the notebook *source* + the static PNG,
40454045
the interactive path as `marimo run tools/viz/esper_viz.py` (+ the `html-wasm` export command
40464046
for a hosted "live wing" as a deploy-time step, not committed bytes). S3 is communication
40474047
infrastructure, not a falsifiable experiment — no gate; its bar is the 60-second-stranger test.
4048+
4049+
## 2026-07-26 11:59 — S3 Increment 2: the visual surface fans out (learn-loop + rollout GIFs, MAP-Elites illumination)
4050+
4051+
Increment 1 proved the Mojo→trace→renderer pipeline on one artifact (the arc_solve
4052+
held-out triptych). Increment 2 reuses that exact pipeline for the three artifacts named
4053+
in the plan — turning the engine's invisible behaviours into things a stranger can *see*:
4054+
4055+
- **"Watch it learn" (flip_h learn-loop).** New `--learn-trace PATH` on `src/main.mojo`
4056+
dumps the operator's prediction on the **held-out** input every K=100 ES iters. The
4057+
capture is a viz-only twin, `forward_with_learning_traced` in `esper_evolution.mojo`:
4058+
it replicates `fit_operator`'s annealed schedule EXACTLY (same alpha/sigma rates, same
4059+
`evolve_fast_weights` calls) but snapshots into a **pre-allocated** buffer BETWEEN ES
4060+
steps — never inside the SIMD hot loop — and serializes once after the fit. The
4061+
production `forward_with_learning` / `fit_operator` are untouched, so no measured number
4062+
moves. The trace shows a clean trajectory: identity (0.00) → 0.62 by iter 500 → 1.00 by
4063+
iter ~3900.
4064+
- **A repertoire behaviour rolled out (sandbox GIF).** New `src/sandbox_trace.mojo` loads a
4065+
MAP-Elites `.rep`, picks the highest-occupancy elite, and rolls its policy out via a new
4066+
viz-only `sandbox_rollout_trace` twin in `sandbox.mojo` that snapshots the whole grid each
4067+
tick (avatar stamped) into a caller buffer — the ES-critical rollouts stay untouched. The
4068+
render reads unmistakably: the avatar paints, gravity pulls the paint down, and the world
4069+
fills floor-up over 64 ticks.
4070+
- **MAP-Elites illumination (zero Mojo change).** `tools/viz/render_repertoire.py` reads the
4071+
*existing* `.rep` binary (`EliteMap.save`) directly — avatar-final-position coverage
4072+
(228/256 behaviours reached) + the 4×4 mean paint field showing the gravity-well bias.
4073+
4074+
Tooling decision — **GIFs with no new dependency.** No Pillow/imageio in the base venv, and
4075+
adding one would tax the "reproduce in one command" claim. Since Esper's grids already ARE
4076+
palette indices, I wrote a self-contained GIF89a/LZW encoder in `tools/viz/gifpng.py`
4077+
(shared with the PNG writer, mirroring `palette.py`'s single-source ethos) — validated by a
4078+
full encode→decode round-trip over every frame (real GIF decoders use exactly the
4079+
one-code-early width boundary the round-trip confirmed). Everything stays numpy + stdlib.
4080+
4081+
`./esper fast` stays green (3/3 held-out still solved) — the three Mojo additions are
4082+
strictly additive/flag-gated and perturb nothing on the measured path.

docs/assets/learn.gif

34.2 KB
Loading

docs/assets/learn.png

1.59 KB
Loading

docs/assets/repertoire.png

3.69 KB
Loading

docs/assets/rollout.gif

134 KB
Loading

docs/assets/rollout.png

8.68 KB
Loading

src/esper_evolution.mojo

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from std.memory import alloc, memset_zero, memcpy, UnsafePointer
22
from std.sys import simd_width_of, size_of, has_accelerator
3-
from std.math import fma, exp, log
3+
from std.math import fma, exp, log, round
44
from std.random import randn_float64
55
from std.collections import List
66
from std.algorithm import parallelize
@@ -410,6 +410,120 @@ def forward_with_learning(
410410
return result^
411411

412412

413+
# Row-major int JSON for one grid slice (rounds the Float32 cells, as the trace
414+
# consumers expect ints). Shared shape with arc_solve._grid_json — kept local so
415+
# the runtime path never imports the viz module.
416+
def _grid_json_ev(
417+
p: UnsafePointer[Float32, MutAnyOrigin], rows: Int, cols: Int
418+
) -> String:
419+
var s = String("[")
420+
for r in range(rows):
421+
if r > 0:
422+
s += String(",")
423+
s += String("[")
424+
for c in range(cols):
425+
if c > 0:
426+
s += String(",")
427+
s += String(Int(round(p[r * cols + c])))
428+
s += String("]")
429+
s += String("]")
430+
return s
431+
432+
433+
# ==========================================
434+
# Traced forward pass ("watch it learn")
435+
# ==========================================
436+
# A viz-only twin of forward_with_learning that snapshots the evolving prediction
437+
# on the held-out test input every `snap_every` ES iterations, so an offline
438+
# renderer can show the operator converging from the identity prior to the fitted
439+
# transform. It replicates fit_operator's annealed schedule EXACTLY (the same
440+
# alpha/sigma rates and evolve_fast_weights calls) so the fit it traces is a
441+
# faithful ES run — the schedule mirror below MUST track fit_operator. The
442+
# snapshot buffer is allocated ONCE up front; each snapshot is an OperatorMemory
443+
# apply BETWEEN ES steps, never inside evolve_fast_weights' hot loop; the JSON is
444+
# assembled and returned to the caller, written to disk once after the fit. The
445+
# production forward_with_learning / fit_operator are untouched, so this path
446+
# cannot perturb any measured number.
447+
def forward_with_learning_traced(
448+
node: UnsafePointer[HopeNode, MutAnyOrigin],
449+
demonstrations: List[ArcTaskPair],
450+
test_input: ArcGrid,
451+
truth: ArcGrid,
452+
snap_every: Int,
453+
mut trace_buf: String,
454+
) raises -> ArcGrid:
455+
var capacity = test_input.size()
456+
for d in range(len(demonstrations)):
457+
var in_area = demonstrations[d].input_grid.size()
458+
if in_area > capacity:
459+
capacity = in_area
460+
var out_area = demonstrations[d].output_grid.size()
461+
if out_area > capacity:
462+
capacity = out_area
463+
464+
var workspace = ESWorkspace[OperatorMemory](capacity)
465+
var rows = test_input.rows
466+
var cols = test_input.cols
467+
var area = test_input.size()
468+
var step = snap_every if snap_every > 0 else FIT_ITERS
469+
470+
# One pre-allocated snapshot buffer (no per-iteration allocation): the
471+
# in-loop snapshots plus a final post-fit frame.
472+
var n_snaps = FIT_ITERS // step + 2
473+
var snaps = alloc[Float32](n_snaps * area)
474+
var snap_iter = List[Int]()
475+
476+
var alpha_rate = log(FIT_ALPHA1 / FIT_ALPHA0) / Float32(FIT_ITERS)
477+
var sigma_rate = log(FIT_SIGMA1 / FIT_SIGMA0) / Float32(FIT_ITERS)
478+
var s = 0
479+
for t in range(FIT_ITERS):
480+
if t % step == 0:
481+
OperatorMemory.apply(node[].fast, test_input, snaps + s * area)
482+
snap_iter.append(t)
483+
s += 1
484+
var alpha = FIT_ALPHA0 * exp(alpha_rate * Float32(t))
485+
var sigma = FIT_SIGMA0 * exp(sigma_rate * Float32(t))
486+
evolve_fast_weights[OperatorMemory](
487+
node[].fast,
488+
workspace,
489+
node[].slow,
490+
demonstrations,
491+
FIT_N,
492+
alpha,
493+
sigma,
494+
FIT_REG,
495+
)
496+
497+
var result = ArcGrid(rows, cols)
498+
OperatorMemory.apply(node[].fast, test_input, result.data)
499+
# Final converged frame.
500+
OperatorMemory.apply(node[].fast, test_input, snaps + s * area)
501+
snap_iter.append(FIT_ITERS)
502+
s += 1
503+
504+
# Assemble one JSON object: fixed input/truth + the ordered frame stack.
505+
trace_buf += String('{"rows":') + String(rows)
506+
trace_buf += String(',"cols":') + String(cols)
507+
trace_buf += String(',"iters":') + String(FIT_ITERS)
508+
trace_buf += String(',"snap_every":') + String(step)
509+
trace_buf += String(',"input":') + _grid_json_ev(
510+
test_input.data, rows, cols
511+
)
512+
trace_buf += String(',"truth":') + _grid_json_ev(truth.data, rows, cols)
513+
trace_buf += String(',"frames":[')
514+
for i in range(s):
515+
if i > 0:
516+
trace_buf += String(",")
517+
trace_buf += String('{"iter":') + String(snap_iter[i])
518+
trace_buf += String(',"grid":')
519+
trace_buf += _grid_json_ev(snaps + i * area, rows, cols)
520+
trace_buf += String("}")
521+
trace_buf += String("]}")
522+
523+
snaps.free()
524+
return result^
525+
526+
413527
# ==========================================
414528
# Meta-learning the slow prior (second timescale)
415529
# ==========================================

src/main.mojo

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ from hope import (
1111
OP_DIM,
1212
seed_identity_operator,
1313
)
14-
from esper_evolution import forward_with_learning
14+
from esper_evolution import (
15+
forward_with_learning,
16+
forward_with_learning_traced,
17+
)
1518
from arc_io import exact_match
1619
from std.collections import List
1720
from std.random import seed, random_float64
21+
from std.sys import argv
1822

1923

2024
def rand_grid(rows: Int, cols: Int) -> ArcGrid:
@@ -56,7 +60,27 @@ def main() raises:
5660
var test = rand_grid(rows, cols)
5761
var truth = flip_h_grid(test)
5862

59-
var result = forward_with_learning(node, demos, test)
63+
# Optional `--learn-trace PATH`: dump the operator's per-K-iter prediction on
64+
# the held-out input as it converges (offline "watch it learn" viz). Purely
65+
# additive — the default forward pass and its numbers are unchanged.
66+
var trace_path = String("")
67+
var args = argv()
68+
for i in range(len(args)):
69+
if String(args[i]) == String("--learn-trace") and i + 1 < len(args):
70+
trace_path = String(args[i + 1])
71+
72+
var result: ArcGrid
73+
if trace_path.byte_length() > 0:
74+
var trace_buf = String("")
75+
result = forward_with_learning_traced(
76+
node, demos, test, truth, 100, trace_buf
77+
)
78+
var f = open(trace_path, "w")
79+
f.write(trace_buf)
80+
f.close()
81+
print(" wrote learn-trace:", trace_path)
82+
else:
83+
result = forward_with_learning(node, demos, test)
6084

6185
print("Esper forward pass complete (learned flip_h from demonstrations).")
6286
print(

src/sandbox.mojo

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,40 @@ def sandbox_rollout_state(
465465
sandbox_bc(scratch_grid, r, c, bc_out)
466466

467467

468+
# Viz-only rollout twin: identical dynamics to sandbox_rollout_state, but also
469+
# snapshots the whole grid after each of the SB_T ticks into `frames`
470+
# (SB_T * SB_CELLS, caller-owned — no per-tick allocation), so an offline
471+
# renderer can animate the world evolving. The avatar's cell is stamped as
472+
# SB_AVATAR_MARK in the copy (not in the live grid) so the viewer can see where
473+
# the policy is acting. Separate from the ES-critical rollouts, which are
474+
# untouched. Returns nothing measured — pure visualization.
475+
comptime SB_AVATAR_MARK = Float32(11.0)
476+
477+
478+
def sandbox_rollout_trace(
479+
weights: UnsafePointer[Float32, MutAnyOrigin],
480+
task: SandboxTask,
481+
scratch_grid: UnsafePointer[Float32, MutAnyOrigin],
482+
obs: UnsafePointer[Float32, MutAnyOrigin],
483+
logits: UnsafePointer[Float32, MutAnyOrigin],
484+
frames: UnsafePointer[Float32, MutAnyOrigin],
485+
):
486+
memcpy(dest=scratch_grid, src=task.grid, count=SB_CELLS)
487+
var r = task.start_r
488+
var c = task.start_c
489+
var brush = task.start_brush
490+
for t in range(SB_T):
491+
sandbox_obs(scratch_grid, r, c, brush, t, obs)
492+
policy_forward(weights, obs, logits)
493+
var action = policy_argmax(logits)
494+
sandbox_step(
495+
scratch_grid, r, c, brush, task.grav_dir, task.grav_rate, action
496+
)
497+
var dst = frames + t * SB_CELLS
498+
memcpy(dest=dst, src=scratch_grid, count=SB_CELLS)
499+
dst[r * SB_COLS + c] = SB_AVATAR_MARK
500+
501+
468502
# ==========================================
469503
# CellSet — the coverage metric's container
470504
# ==========================================

0 commit comments

Comments
 (0)