Skip to content

Commit ceabce1

Browse files
tacioclaude
andcommitted
Port objective-reward benchmark from claude/test-app-objective-rewards
Brings the objective-reward harness from branch claude/test-app-objective-rewards-rjcquh (commit 3f3a7a2) onto master. That branch forked before the Mojo 1.0 migration, so it was written in pre-1.0 syntax (fn/alias/old imports, the removed Tensor type) and could not compile or merge cleanly. Rather than a raw merge, the genuinely-new value is reimplemented in 1.0 idioms: - arc_io.mojo: add exact_match(), a SIMD discrete reward (fraction of cells correct after rounding) mirroring ARC's per-cell scoring. - synth_tasks.py: deterministic task generator (flip/transpose/recolor/ shift) reusing _save_grid so the .bin layout stays the single source of truth (pure Python, copied as-is). - tests/test_es_convergence.mojo: asserts reward climbs toward a known target and reaches an exact match (auto-run via the test_*.mojo glob). - benchmark.mojo: end-to-end harness reporting MSE reward, exact-match %, and aggregate solve rate over generated tasks. - run_tests.sh: generate a small task set and run the benchmark. Deliberately dropped: the branch's esper_evolution.mojo change (constant epsilon -> Gaussian noise). Master's migration already replaced that with real Gaussian noise *plus* antithetic mirrored sampling, which strictly supersedes it. Convergence test now solves the target (exact-match 1.0) and the benchmark solves 4/4 generated tasks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 47bdc8b commit ceabce1

5 files changed

Lines changed: 366 additions & 0 deletions

File tree

run_tests.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,18 @@ done
2626
echo "Running src/main.mojo (end-to-end driver)..."
2727
mojo run -I src src/main.mojo
2828

29+
# Objective-reward benchmark: generate a small deterministic task set, then fit
30+
# each target with the ES loop and report the aggregate solve rate.
31+
echo "Running objective-reward benchmark (src/benchmark.mojo)..."
32+
BENCH_DIR="$(mktemp -d)"
33+
trap 'rm -rf "$BENCH_DIR"' EXIT
34+
python - "$BENCH_DIR" <<'PY'
35+
import sys
36+
sys.path.insert(0, "src")
37+
from synth_tasks import generate_tasks
38+
generate_tasks("flip_h", sys.argv[1], count=4, rows=4, cols=4, seed=0)
39+
print("Generated benchmark tasks in", sys.argv[1])
40+
PY
41+
mojo run -I src src/benchmark.mojo "$BENCH_DIR"/flip_h_*_out.bin
42+
2943
echo "All tests passed successfully."

src/arc_io.mojo

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from std.sys import simd_width_of, size_of
22
from std.memory import memcpy, UnsafePointer
3+
from std.math import round
34
from hope import ArcGrid
45

56
comptime nelts = simd_width_of[DType.float32]()
@@ -63,3 +64,32 @@ def calculate_fitness(
6364
mse_sum += diff * diff
6465

6566
return -(mse_sum / Float32(size))
67+
68+
69+
def exact_match(
70+
pred_ptr: UnsafePointer[Float32, MutAnyOrigin],
71+
target_ptr: UnsafePointer[Float32, MutAnyOrigin],
72+
size: Int,
73+
) -> Float32:
74+
"""Discrete objective reward: fraction of cells that match exactly.
75+
76+
ARC grids hold integer colors (0-9) while the engine evolves continuous
77+
weights, so each prediction is rounded to the nearest integer before
78+
comparison. This mirrors ARC's all-or-nothing per-cell scoring and
79+
complements the continuous negative-MSE signal from `calculate_fitness`.
80+
Same SIMD main-loop + scalar-remainder shape as the rest of the hot path.
81+
"""
82+
var matches = Float32(0.0)
83+
84+
for i in range(0, size - nelts + 1, nelts):
85+
var p_vec = round(pred_ptr.load[width=nelts](i))
86+
var t_vec = round(target_ptr.load[width=nelts](i))
87+
matches += p_vec.eq(t_vec).cast[DType.float32]().reduce_add()
88+
89+
var remainder = size % nelts
90+
if remainder > 0:
91+
for i in range(size - remainder, size):
92+
if round(pred_ptr[i]) == round(target_ptr[i]):
93+
matches += 1.0
94+
95+
return matches / Float32(size)

src/benchmark.mojo

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from std.sys import argv
2+
from std.memory import alloc, UnsafePointer
3+
from std.random import seed
4+
5+
from arc_io import load_arc_grid, calculate_fitness, exact_match
6+
from esper_evolution import ESWorkspace, evolve_fast_weights
7+
8+
# ==========================================================================
9+
# Esper objective-reward benchmark harness.
10+
#
11+
# Each argument is a path to a compiled target grid (`*_out.bin`, produced by
12+
# `src/synth_tasks.py`). For every task we evolve a fresh fast-weight buffer to
13+
# fit that target, then report two objective rewards:
14+
# * reward = negative MSE (continuous; closer to 0 is better)
15+
# * match% = exact_match (discrete; fraction of cells correct after round)
16+
# A task counts as solved when match% >= SOLVE_THRESHOLD. The aggregate solve
17+
# rate is the headline objective metric for the engine.
18+
#
19+
# Run from the project root, e.g.:
20+
# python src/synth_tasks.py --transform flip_h --out data_bin --count 8
21+
# mojo run -I src src/benchmark.mojo data_bin/flip_h_*_out.bin
22+
# ==========================================================================
23+
24+
comptime SOLVE_THRESHOLD = Float32(0.99)
25+
comptime ES_SAMPLES = 96
26+
comptime ES_SIGMA = Float32(0.1)
27+
comptime ES_ITERS = 150
28+
29+
30+
def solve_task(target_path: String) raises -> Float32:
31+
"""Evolve fast weights to fit one target grid; return its exact-match fraction.
32+
"""
33+
var target = load_arc_grid(target_path)
34+
var size = target.size()
35+
var tptr = target.data
36+
37+
# Fresh fast weights start at zero (no memory carried between tasks).
38+
var fast = alloc[Float32](size)
39+
for i in range(size):
40+
fast[i] = 0.0
41+
42+
var workspace = ESWorkspace(size)
43+
44+
# Scale the learning rate with grid size so the effective step
45+
# (~2 * alpha / size) stays in a stable range across grid dimensions.
46+
var alpha = Float32(0.1) * Float32(size)
47+
48+
for _ in range(ES_ITERS):
49+
evolve_fast_weights(fast, workspace, tptr, ES_SAMPLES, alpha, ES_SIGMA)
50+
51+
var reward = calculate_fitness(fast, tptr, size)
52+
var match_frac = exact_match(fast, tptr, size)
53+
54+
print(" task:", target_path)
55+
print(" reward (-MSE):", reward, " match%:", match_frac * 100.0)
56+
57+
fast.free()
58+
return match_frac
59+
60+
61+
def main() raises:
62+
seed(0)
63+
64+
var args = argv()
65+
if len(args) < 2:
66+
print(
67+
"Usage: mojo run -I src src/benchmark.mojo <target_grid.bin>"
68+
" [more.bin ...]"
69+
)
70+
print("Generate tasks first, e.g.:")
71+
print(
72+
" python src/synth_tasks.py --transform flip_h --out data_bin"
73+
" --count 8"
74+
)
75+
return
76+
77+
var total = len(args) - 1
78+
var solved = 0
79+
80+
print("Esper objective-reward benchmark over", total, "task(s)")
81+
for idx in range(1, len(args)):
82+
var match_frac = solve_task(String(args[idx]))
83+
if match_frac >= SOLVE_THRESHOLD:
84+
solved += 1
85+
86+
var solve_rate = Float32(solved) / Float32(total) * 100.0
87+
print("--------------------------------------------------")
88+
print("Solved", solved, "/", total, " (solve rate:", solve_rate, "% )")
89+
90+
# Non-zero exit (via raised error) if nothing solved, so the harness/CI
91+
# treats a fully-failing benchmark as a failure.
92+
if solved == 0:
93+
raise Error(
94+
"ERROR: benchmark solved 0 tasks; the ES loop is not learning."
95+
)

src/synth_tasks.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Synthetic, deterministic grid-transformation task generator for Esper.
3+
4+
Esper targets ARC-AGI, where every task has a unique, machine-checkable
5+
ground-truth output grid -- i.e. an *objective reward* (exact pixel match).
6+
Real ARC tasks also need a full forward pass and a primitive library, which the
7+
engine does not have yet. This generator instead emits *single deterministic
8+
transforms* (recolor, flip, transpose, shift). Each task therefore has an
9+
unambiguous target grid, so the Evolution-Strategy loop can be scored objectively
10+
(negative MSE via `calculate_fitness`, exact-match % via `exact_match`) and we can
11+
prove the learning loop actually converges before tackling full ARC reasoning.
12+
13+
Grids are written in the exact `.bin` layout the Mojo engine reads
14+
(`load_arc_grid` in `arc_io.mojo`): two little-endian int64 (rows, cols) followed
15+
by the flattened grid as float32. We reuse `_save_grid` from `arc_compiler.py`
16+
so the writer stays the single source of truth for that format.
17+
18+
Usage:
19+
python src/synth_tasks.py --transform flip_h --out data_bin --count 8
20+
python src/synth_tasks.py --transform recolor --rows 6 --cols 6 --seed 0
21+
"""
22+
23+
import argparse
24+
import os
25+
import random as _random
26+
27+
# Reuse the canonical .bin writer so the on-disk format cannot drift from the
28+
# compiler / engine contract.
29+
from arc_compiler import _save_grid
30+
31+
# ARC grids use integer "colors" 0-9.
32+
NUM_COLORS = 10
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# Deterministic transforms. Each takes a 2-D list-of-lists grid and returns a
37+
# new grid of the same shape (so input and target share dimensions, which keeps
38+
# the engine's fixed-size fast-weight buffer valid).
39+
# ---------------------------------------------------------------------------
40+
def _identity(grid):
41+
return [row[:] for row in grid]
42+
43+
44+
def _flip_h(grid):
45+
return [list(reversed(row)) for row in grid]
46+
47+
48+
def _flip_v(grid):
49+
return [row[:] for row in reversed(grid)]
50+
51+
52+
def _transpose(grid):
53+
# Square grids only keep the same shape under transpose; the generator
54+
# enforces rows == cols when this transform is selected.
55+
return [list(col) for col in zip(*grid)]
56+
57+
58+
def _recolor(grid):
59+
# Fixed cyclic palette shift: color c -> (c + 1) % NUM_COLORS.
60+
return [[(c + 1) % NUM_COLORS for c in row] for row in grid]
61+
62+
63+
def _shift(grid):
64+
# Roll every row right by one (wrap-around). Deterministic and shape-stable.
65+
return [[row[-1]] + row[:-1] for row in grid]
66+
67+
68+
TRANSFORMS = {
69+
"identity": _identity,
70+
"flip_h": _flip_h,
71+
"flip_v": _flip_v,
72+
"transpose": _transpose,
73+
"recolor": _recolor,
74+
"shift": _shift,
75+
}
76+
77+
78+
def _random_grid(rows, cols, rng):
79+
return [[rng.randrange(NUM_COLORS) for _ in range(cols)] for _ in range(rows)]
80+
81+
82+
def generate_tasks(transform, out_dir, count, rows, cols, seed):
83+
"""
84+
Generate `count` (input, target) grid pairs for `transform` and write them as
85+
`.bin` files into `out_dir`. Returns the list of (input_path, target_path)
86+
tuples written.
87+
"""
88+
if transform not in TRANSFORMS:
89+
raise ValueError(
90+
"Unknown transform %r; choose from %s"
91+
% (transform, ", ".join(sorted(TRANSFORMS)))
92+
)
93+
if transform == "transpose" and rows != cols:
94+
raise ValueError("transpose requires a square grid (rows == cols)")
95+
96+
os.makedirs(out_dir, exist_ok=True)
97+
fn = TRANSFORMS[transform]
98+
rng = _random.Random(seed)
99+
100+
pairs = []
101+
for i in range(count):
102+
grid_in = _random_grid(rows, cols, rng)
103+
grid_out = fn(grid_in)
104+
105+
in_path = os.path.join(out_dir, "%s_%d_in.bin" % (transform, i))
106+
out_path = os.path.join(out_dir, "%s_%d_out.bin" % (transform, i))
107+
_save_grid(grid_in, in_path)
108+
_save_grid(grid_out, out_path)
109+
pairs.append((in_path, out_path))
110+
111+
return pairs
112+
113+
114+
def _parse_args():
115+
p = argparse.ArgumentParser(description=__doc__)
116+
p.add_argument(
117+
"--transform",
118+
default="flip_h",
119+
choices=sorted(TRANSFORMS),
120+
help="Which deterministic transform maps input -> target.",
121+
)
122+
p.add_argument("--out", default="data_bin", help="Output directory for .bin files.")
123+
p.add_argument("--count", type=int, default=8, help="Number of task pairs to emit.")
124+
p.add_argument("--rows", type=int, default=6, help="Grid rows.")
125+
p.add_argument("--cols", type=int, default=6, help="Grid cols.")
126+
p.add_argument("--seed", type=int, default=0, help="RNG seed (reproducible tasks).")
127+
return p.parse_args()
128+
129+
130+
if __name__ == "__main__":
131+
args = _parse_args()
132+
written = generate_tasks(
133+
args.transform, args.out, args.count, args.rows, args.cols, args.seed
134+
)
135+
print(
136+
"Generated %d %s task pair(s) (%dx%d) in %s/"
137+
% (len(written), args.transform, args.rows, args.cols, args.out)
138+
)
139+
for in_path, out_path in written:
140+
print(" %s -> %s" % (os.path.basename(in_path), os.path.basename(out_path)))

tests/test_es_convergence.mojo

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from std.memory import alloc, UnsafePointer
2+
from std.random import seed
3+
4+
# Run from the project root with `mojo run -I src tests/test_es_convergence.mojo`
5+
# so these cross-directory imports resolve.
6+
from esper_evolution import ESWorkspace, evolve_fast_weights
7+
from arc_io import calculate_fitness, exact_match
8+
9+
# ==========================================================================
10+
# Objective-reward convergence test.
11+
#
12+
# Master's ES uses real Gaussian noise with antithetic sampling, so the fast
13+
# weights should march toward a known target grid: the reward (negative MSE)
14+
# must climb toward 0 and the rounded prediction must reach an exact match.
15+
# This is the smallest end-to-end check that the arena/IO/ES/SIMD/reward
16+
# plumbing actually learns (it would fail for the old constant-epsilon RNG,
17+
# whose gradient estimate was degenerate).
18+
# ==========================================================================
19+
20+
21+
def main() raises:
22+
seed(0)
23+
24+
comptime size = 64
25+
26+
# Build an integer-valued target grid, like an ARC grid (colors 0-9).
27+
var target = alloc[Float32](size)
28+
for i in range(size):
29+
target[i] = Float32(i % 10)
30+
31+
# Fast weights start at zero, i.e. far from the target.
32+
var fast = alloc[Float32](size)
33+
for i in range(size):
34+
fast[i] = 0.0
35+
36+
var workspace = ESWorkspace(size)
37+
38+
var N = 96
39+
var alpha = Float32(6.0)
40+
var sigma = Float32(0.1)
41+
var iters = 60
42+
43+
var initial_reward = calculate_fitness(fast, target, size)
44+
var prev = initial_reward
45+
var improvements = 0
46+
47+
for _ in range(iters):
48+
evolve_fast_weights(fast, workspace, target, N, alpha, sigma)
49+
var r = calculate_fitness(fast, target, size)
50+
if r > prev:
51+
improvements += 1
52+
prev = r
53+
54+
var final_reward = calculate_fitness(fast, target, size)
55+
var match_frac = exact_match(fast, target, size)
56+
57+
print("Initial reward (-MSE):", initial_reward)
58+
print("Final reward (-MSE):", final_reward)
59+
print("Exact-match fraction :", match_frac)
60+
print("Improving iterations :", improvements, "/", iters)
61+
62+
# 1. Reward must improve overall.
63+
if final_reward <= initial_reward:
64+
fast.free()
65+
target.free()
66+
raise Error("ERROR: ES did not improve reward toward the target.")
67+
68+
# 2. Improvement must be sustained, not a single lucky step. We require at
69+
# least a third of iterations to improve: a degenerate loop plateaus
70+
# almost immediately, while a working optimizer climbs steadily before
71+
# jittering near the optimum.
72+
if improvements * 3 < iters:
73+
fast.free()
74+
target.free()
75+
raise Error(
76+
"ERROR: ES reward did not improve steadily across iterations."
77+
)
78+
79+
# 3. The objective (discrete) reward must be (near) solved.
80+
if match_frac < 0.95:
81+
fast.free()
82+
target.free()
83+
raise Error("ERROR: ES did not converge to an exact grid match.")
84+
85+
fast.free()
86+
target.free()
87+
print("ES convergence test passed.")

0 commit comments

Comments
 (0)