/ receipts / llm-bench-tower-building
Open source · GitHub

llm-bench-tower-building

An LLM benchmark where the test is a tower: build the tallest one you can, one block at a time, under a physics uncertainty contract.

Most LLM benchmarks grade text. This one grades a pile of blocks. The model gets a small SDK — get_inventory, observe, place_block — and a simple objective: stack the inventory into the tallest tower that is still standing when the physics stops. The score is the max top-y over blocks that keep a contact chain to the ground after everything settles. No partial credit, no rubric, no judge model. Either the tower stands or it doesn't, and a deterministic simulator is the whole grading committee.

The uncertainty contract

The benchmark's core mechanic is a trade-off borrowed straight from physics: sigmaX * sigmaV = K, constant per challenge. Every place_block call takes a focus parameter between 0 and 1. Focus 1 means exact position and wild velocity — the block lands where you asked but may kick sideways at spawn. Focus 0 means exact velocity and wild position — it moves the way you asked but may appear off-target. Focus 0.5 splits the uncertainty evenly. Since the requested velocity is the mean of the sampled velocity, a model can put some focus on velocity and gently press a block into place instead of gambling on a kick. Allocating that trade-off well is the skill the benchmark exists to measure.

How it works

Physics runs headless in src/core on rapier3d, deterministic under a seeded RNG: validation, Gaussian sampling of the placement error, simulate-to-settle, replay log. The three.js viewer is a viewer only — replays re-simulate from (challengeId, seed, placement log) with the same core code the scorer used, so what you watch is what was scored. The harness supports two conversation modes. In the default episodic mode, each attempt starts with a fresh context; when it ends, the model distills what it learned into a persistent notebook via update_notebook, and the next attempt begins with only the system prompt, a harness-kept history table, and that notebook — keeping the live context around 10–15k tokens no matter how many attempts run. Session mode keeps the original single-conversation behavior for comparison. Models starting with claude run on the Anthropic API; everything else uses OpenAI-compatible chat completions, with --base-url for other providers.

What's real today

Per the repo's own status section: the core sim, ten challenges (bricks, bricks50, bricks100, bricks50k2, bricks50k4, mixed, sparse, storm, pillars, slick), the SDK and its spatial utilities, a scripted naive baseline agent, the replay viewer, the LLM harness in both modes, and a local leaderboard board are all working. The test suite is vitest and covers the RNG, the uncertainty contract, episode validation, determinism, and scoring. A full model × challenge coverage matrix already lives in replays/ under group cov-1, and the known tuning knobs — settle thresholds, per-challenge K — are listed as open decisions in the project plan. The README's replays show the same model on the same challenge producing a clean 7.3m build on one attempt and, on another, reaching 7.9m before block 13 brought it down — then rebuilding to 4.0m from the rubble. Same weights, different tower. That's the variance the benchmark is built to expose.

Run it yourself

It's TypeScript, MIT licensed, and the quickstart is four commands:

npm install
npm test                              # unit + determinism + scoring tests
npm run agent:naive -- --challenge bricks --seed 1   # scripted baseline
npm run dev                           # viewer -> http://localhost:5173/src/viewer/

To bench an actual model:

npm run bench -- --model claude-fable-5 --challenge bricks --seeds 3x11

--seeds 3x11 runs three attempts on seed 11 — same seed across attempts is how you measure in-context improvement — while --seeds 11,12,13 runs one attempt per seed. Every run writes one replay per attempt, a JSON score summary, and a full transcript of every turn. The repo is a math-vs-vibes project, and it runs on the same house rule as the show: the claim is only as good as the replay.

★ View on GitHub
The full README, verbatim

llm-bench-tower-building

Claude Fable 5 builds a clean 7.3m post-and-lintel tower Claude Fable 5 reaches 7.9m, collapses on block 13, rebuilds to 4.0m
The same model, attempt 2: a clean 7.3m build. Attempt 1: 7.9m — then block 13 brings it all down. It rebuilds to 4.0m from the rubble.

An LLM benchmark: build the tallest tower you can by placing blocks through a small SDK — under a position/velocity uncertainty contract. You can place a block precisely, or control its velocity precisely, but not both:

sigmaX * sigmaV = K        (constant per challenge)

focus = 1   -> exact position, wild velocity (block may kick sideways at spawn)
focus = 0   -> exact velocity, wild position (block may appear off-target)
focus = 0.5 -> balanced      (sigmaX = sigmaX0, sigmaV = sigmaV0)

The requested velocity is the mean of the sampled velocity, so allocating some focus to velocity lets an agent "press" a block gently into place instead of gambling on a sideways kick — the trade-off is the benchmark's core skill.

Physics is simulated headlessly (rapier3d, deterministic with a seeded RNG). three.js is a viewer only — replays re-simulate from (challengeId, seed, placement log) with the same core code the scorer used.

Quickstart

npm install
npm test                              # unit + determinism + scoring tests
npm run agent:naive -- --challenge bricks --seed 1   # scripted baseline, writes replays/
npm run dev                           # viewer -> http://localhost:5173/src/viewer/

The viewer loads ?replay=/replays/<file>.json (defaults to the naive baseline run). Play/pause, speed, and a scrub slider are at the bottom.

Running LLM agents

npm run bench -- --model claude-fable-5 --challenge bricks --seeds 3x11
npm run bench -- --model gpt-5.6-sol  --challenge mixed --seeds 11,12,13

--seeds 3x11 = three attempts on seed 11 (same seed across attempts measures in-context improvement); --seeds 11,12,13 = one attempt per seed. Models starting with claude use the Anthropic API (ANTHROPIC_API_KEY / ANTHROPIC_API_KEY_PERSONAL); everything else uses OpenAI-compatible chat completions (OPENAI_API_KEY, --base-url for other providers).

--mode episodic (default) starts each attempt with a fresh conversation: when an attempt ends the model distills what it learned into a persistent notebook (update_notebook), the context resets, and the next attempt begins with only the system prompt, the harness-kept history table, and that notebook — so the live context stays ~10–15k tokens no matter how many attempts. --mode session is the original single-conversation behavior, kept for comparison.

Provider knobs (env): BENCH_HTTP_TIMEOUT_MS (per-request timeout, default 300000) and BENCH_MAX_TOKENS (cap completion length) — both default off/unset and exist for slow or non-terminating reasoning models (e.g. k3, whose server can otherwise generate past every timeout on long planning turns).

scripts/coverage.sh runs a model × challenge coverage matrix in parallel lanes (one concurrent run per model) — edit the lane lists to taste.

Per run the harness writes: one replay per attempt (viewable in the viewer), a run-<label>-<challenge>-<runId>.json score summary (including mode and notebook entries), and a full transcript-*.json of every turn. Attempts auto-advance when the inventory is exhausted; the model can also abandon early via next_episode.

Layout

src/core/    authoritative headless sim (no DOM): physics, uncertainty, scoring
  types.ts       shared interfaces (blocks, placement API, challenges, replay)
  sim.ts         rapier world wrapper: step, settle detection, contact queries
  uncertainty.ts focus -> sigmas (sigmaX * sigmaV = K), Gaussian sampling
  episode.ts     validation -> sample -> simulate-to-settle -> replay log
  scoring.ts     tower height via contact chain to the ground
  challenges.ts  10 challenges: bricks, bricks50, bricks100, bricks50k2, bricks50k4, mixed, sparse, storm, pillars, slick
src/sdk/     what an agent (LLM or scripted) drives
  tools.ts       tool schemas (get_inventory / observe / place_block) + SDK_DOC
  utils.ts       spatial helpers (rotatedExtents, stackCenterY, footprint, ...)
  client.ts      EpisodeClient: typed wrapper + callTool dispatch
src/viewer/  three.js replay viewer (re-simulates replays, scrub playback)
src/agents/  naive.ts — scripted baseline agent, writes replays/*.json
tests/       vitest: rng, uncertainty contract, episode validation, determinism, scoring

Placement API (summary)

place_block({
  blockId: 'b1',
  position: [0, 0.31, 0],   // desired center, meters, y-up
  yawDeg: 90,               // optional, default 0
  orientation: 'upright',   // optional named pose: box flat|side|upright, cylinder upright|flat
  quat: [0, 0, 0.7071, 0.7071], // optional full-resolution rotation (precedence over orientation/yaw)
  velocity: [0, -0.3, 0],   // optional desired MEAN velocity, |v| <= maxSpeed
  focus: 0.6,               // required: precision allocation, [0, 1]
})
// -> { ok, actual: { position, velocity, sigma: {x, v} },
//      settle: { outcome, tower, spawnOverlap, spawnPenetration, ... } }
// or { ok: false, error } — validation errors are retryable and free

Score = max top-y over blocks with a contact chain to the ground, after the whole inventory settles. Peak height is tracked separately. Blocks that fall off the ground plane are lost. Spawning inside another block is not an error — physics resolves it (violently); the result reports spawnOverlap.

Leaderboard

http://localhost:5173/src/board/ lists every attempt (sortable, filterable by challenge and group) with watch links into the viewer. It reads replays/index.json, regenerated automatically after every run or via npm run board. Tag related runs with --group <name> to compare them as a set.

Status

Working: core sim, 10 challenges, SDK + spatial utilities, naive baseline agent, replay viewer, LLM harness (episodic + session modes), leaderboard board. Full model × challenge coverage matrix lives in replays/ (group cov-1); known tuning knobs (settle thresholds, per-challenge K) are listed as open decisions in the project plan.

License

MIT — see LICENSE.


A math vs vibes project.

More posts like this live in The Receipts — the show's blog of things we actually computed. The datasets behind the episodes (every US domestic flight since 2003, the full World Cup match record) are free at /data/.

Repo post · repo created 2026-07-19, posted 2026-08-06. The blocks do not care how confident the model sounded. Block 13 has been asked for comment and has fallen over.