Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +81 -0
- IMPLEMENTATION.md +278 -0
- README.md +147 -6
- __init__.py +16 -0
- arena.py +146 -0
- client.py +33 -0
- demo/__init__.py +0 -0
- demo/ui.py +543 -0
- landscapes.py +273 -0
- models.py +95 -0
- openenv.yaml +7 -0
- openenv_landscapeforge.egg-info/PKG-INFO +15 -0
- openenv_landscapeforge.egg-info/SOURCES.txt +32 -0
- openenv_landscapeforge.egg-info/dependency_links.txt +1 -0
- openenv_landscapeforge.egg-info/entry_points.txt +2 -0
- openenv_landscapeforge.egg-info/requires.txt +11 -0
- openenv_landscapeforge.egg-info/top_level.txt +1 -0
- prompts.py +267 -0
- pyproject.toml +43 -0
- reference_optimizers.py +150 -0
- rewards.py +183 -0
- run_llm_episode.py +281 -0
- sandbox.py +160 -0
- server/__init__.py +11 -0
- server/app.py +90 -0
- server/landscapeforge_environment.py +513 -0
- server/requirements.txt +6 -0
- tests/__init__.py +0 -0
- tests/test_episode.py +150 -0
- uv.lock +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
# Multi-stage build using openenv-base
|
| 8 |
+
# This Dockerfile is flexible and works for both:
|
| 9 |
+
# - In-repo environments (with local OpenEnv sources)
|
| 10 |
+
# - Standalone environments (with openenv from PyPI/Git)
|
| 11 |
+
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
+
|
| 13 |
+
ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
|
| 14 |
+
FROM ${BASE_IMAGE} AS builder
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Ensure git is available (required for installing dependencies from VCS)
|
| 19 |
+
RUN apt-get update && \
|
| 20 |
+
apt-get install -y --no-install-recommends git && \
|
| 21 |
+
rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
# Build argument to control whether we're building standalone or in-repo
|
| 24 |
+
ARG BUILD_MODE=in-repo
|
| 25 |
+
ARG ENV_NAME=landscapeforge
|
| 26 |
+
|
| 27 |
+
# Copy environment code (always at root of build context)
|
| 28 |
+
COPY . /app/env
|
| 29 |
+
|
| 30 |
+
# For in-repo builds, openenv is already vendored in the build context
|
| 31 |
+
# For standalone builds, openenv will be installed via pyproject.toml
|
| 32 |
+
WORKDIR /app/env
|
| 33 |
+
|
| 34 |
+
# Ensure uv is available (for local builds where base image lacks it)
|
| 35 |
+
RUN if ! command -v uv >/dev/null 2>&1; then \
|
| 36 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
| 37 |
+
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
| 38 |
+
mv /root/.local/bin/uvx /usr/local/bin/uvx; \
|
| 39 |
+
fi
|
| 40 |
+
|
| 41 |
+
# Install dependencies using uv sync
|
| 42 |
+
# If uv.lock exists, use it; otherwise resolve on the fly
|
| 43 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 44 |
+
if [ -f uv.lock ]; then \
|
| 45 |
+
uv sync --frozen --no-install-project --no-editable; \
|
| 46 |
+
else \
|
| 47 |
+
uv sync --no-install-project --no-editable; \
|
| 48 |
+
fi
|
| 49 |
+
|
| 50 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 51 |
+
if [ -f uv.lock ]; then \
|
| 52 |
+
uv sync --frozen --no-editable; \
|
| 53 |
+
else \
|
| 54 |
+
uv sync --no-editable; \
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
# Final runtime stage
|
| 58 |
+
FROM ${BASE_IMAGE}
|
| 59 |
+
|
| 60 |
+
WORKDIR /app
|
| 61 |
+
|
| 62 |
+
# Copy the virtual environment from builder
|
| 63 |
+
COPY --from=builder /app/env/.venv /app/.venv
|
| 64 |
+
|
| 65 |
+
# Copy the environment code
|
| 66 |
+
COPY --from=builder /app/env /app/env
|
| 67 |
+
|
| 68 |
+
# Set PATH to use the virtual environment
|
| 69 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 70 |
+
|
| 71 |
+
# Set PYTHONPATH so imports work correctly
|
| 72 |
+
ENV PYTHONPATH="/app/env:$PYTHONPATH"
|
| 73 |
+
|
| 74 |
+
# Health check
|
| 75 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 76 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 77 |
+
|
| 78 |
+
# Run the FastAPI server
|
| 79 |
+
# The module path is constructed to work with the /app/env structure
|
| 80 |
+
ENV ENABLE_WEB_INTERFACE=true
|
| 81 |
+
CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
|
IMPLEMENTATION.md
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LandscapeForge — Implementation Notes (v0.1 code)
|
| 2 |
+
|
| 3 |
+
Status: **env core working end-to-end in-process**; scripted tests pass.
|
| 4 |
+
Ships what §18 v1 of `LANDSCAPEFORGE_DESIGN.md` specifies as the weekend MVP,
|
| 5 |
+
minus training and demo layers.
|
| 6 |
+
|
| 7 |
+
## What's implemented
|
| 8 |
+
|
| 9 |
+
| File | Purpose |
|
| 10 |
+
|---|---|
|
| 11 |
+
| `models.py` | Unified `LandscapeforgeAction` (discriminated by `kind`) + `LandscapeforgeObservation` |
|
| 12 |
+
| `landscapes.py` | 9 analytic template builders with hand-written gradients + `TIER_MENU` + `structural_hints()` |
|
| 13 |
+
| `reference_optimizers.py` | SGD / Momentum / Adam / crude L-BFGS + `run_baseline()` |
|
| 14 |
+
| `sandbox.py` | AST strip (keep only `class Optimizer`), safe globals, SIGALRM timeout; `compile_optimizer()` |
|
| 15 |
+
| `arena.py` | `run_arena()` for Phase-D eval + `auto_test_draft()` for draft-time feedback |
|
| 16 |
+
| `rewards.py` | Terminal reward (`compute_optcoder_reward`) + stepwise feedback (`compute_step_reward`) + `ast_novelty_score` |
|
| 17 |
+
| `server/landscapeforge_environment.py` | OpenEnv `Environment` subclass wiring everything together |
|
| 18 |
+
| `server/app.py` | FastAPI wrapper (scaffold, unchanged) |
|
| 19 |
+
| `client.py` | HTTP client over the unified action schema |
|
| 20 |
+
| `tests/test_episode.py` | 3 scripted episodes, all passing |
|
| 21 |
+
|
| 22 |
+
## Action space (§7.1)
|
| 23 |
+
|
| 24 |
+
Four actions with differentiated budget cost:
|
| 25 |
+
|
| 26 |
+
| Action | Cost | What it returns |
|
| 27 |
+
|---|---|---|
|
| 28 |
+
| `run_baseline(name)` | 2 | Fixed 30-step trajectory `(x_t, f_t, \|g_t\|)`; step count is env-controlled for comparability; source NOT revealed |
|
| 29 |
+
| `draft(code)` | 2 | Auto-test summary on 1 seed × 20 steps + compile_error if any |
|
| 30 |
+
| `inspect(draft_idx, step_range)` | 1 | Per-step detail `(x, f, grad, update_norm, step_size_eff)` from the referenced draft |
|
| 31 |
+
| `commit` | 0 | Terminates, triggers Phase-D arena eval |
|
| 32 |
+
|
| 33 |
+
Budget total: **12 units**. Hard ceiling of 6 drafts per episode prevents brute-force enumeration.
|
| 34 |
+
|
| 35 |
+
**Auto-commit contract:** if the agent never calls `commit`, budget exhaustion auto-triggers the same Phase-D arena evaluation on `state.current_draft` (i.e. the most recent draft the agent submitted). Whether the agent calls `commit` explicitly or hits budget exhaustion, **the most recent draft is always what gets evaluated**. Implemented in `LandscapeforgeEnvironment._finalize_episode` — `current_draft` is evaluated; only "no draft at all" produces worst-case regret. The prompt (`prompts.SYSTEM`) documents this contract to the LLM so it understands it isn't penalized for not committing, but should make sure its *latest* draft is its best one.
|
| 36 |
+
|
| 37 |
+
## Reward
|
| 38 |
+
|
| 39 |
+
Two distinct signals — only the terminal one is used as the training scalar. Stepwise signals are in-context feedback for the LLM.
|
| 40 |
+
|
| 41 |
+
### Terminal reward (the GRPO training scalar)
|
| 42 |
+
|
| 43 |
+
Computed once, at commit (or auto-commit on budget exhaustion), after the full Phase-D arena evaluation. Lives in `obs.reward` and `obs.r_optcoder`.
|
| 44 |
+
|
| 45 |
+
```
|
| 46 |
+
r_total = 1.0 · r_regret
|
| 47 |
+
+ 0.3 · r_convergence
|
| 48 |
+
+ 0.3 · r_robustness
|
| 49 |
+
+ 0.1 · r_novelty (gated: 0 unless r_regret > 0.5)
|
| 50 |
+
− 0.05 · r_budget
|
| 51 |
+
− 0.5 · r_eval_failures
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Range: roughly **[−1.55, +1.65]** in practice. Weights live in `rewards.py` (`W_*`).
|
| 55 |
+
|
| 56 |
+
#### 1. `r_regret` — the main signal (Adam-relative descent, NO `f_min` dependency)
|
| 57 |
+
|
| 58 |
+
**Measures:** how much further the committed optimizer descended on `f(x)` than Adam's default on the same landscape, starting from the same init. Purely relative — does not require knowing the absolute minimum.
|
| 59 |
+
|
| 60 |
+
**Computed:**
|
| 61 |
+
```
|
| 62 |
+
# Before running the Adam baseline, tune its LR per-landscape via a short
|
| 63 |
+
# sweep over {1e-4, 1e-3, 3e-3, 1e-2, 3e-2, 1e-1, 3e-1} on a dedicated seed
|
| 64 |
+
# (30-step each). This keeps the comparison fair — agent must beat Adam-at-
|
| 65 |
+
# best-LR, not Adam-at-PyTorch-default.
|
| 66 |
+
best_lr = tune_adam_lr(f, grad, x0=sweep_seed_init, sweep_steps=30)
|
| 67 |
+
|
| 68 |
+
my_progress = mean over 10 arena seeds of (f_initial − f_final)
|
| 69 |
+
adam_progress = same, running Adam(lr=best_lr) on the same seeds
|
| 70 |
+
denom = max(adam_progress, 0.01 · mean|f_initial| + 1e-6)
|
| 71 |
+
r_regret = clamp( my_progress / denom − 1 , -1, +1 )
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
where `f_initial` and `f_final` are observable per-seed values from `arena.initial_values` and `arena.final_values`. Crashed seeds contribute 0 progress (conservative — "you didn't descend on that seed").
|
| 75 |
+
|
| 76 |
+
The denominator floor (~1% of initial f magnitude) protects against near-zero Adam progress exploding the ratio (e.g. on plateau landscapes where Adam barely moves).
|
| 77 |
+
|
| 78 |
+
**Range:** [−1, +1]
|
| 79 |
+
- `+1.0`: descended ≥ 2× as far as Adam (clipped ceiling)
|
| 80 |
+
- `0.0`: matched Adam's descent exactly
|
| 81 |
+
- `−1.0`: made zero or negative progress while Adam descended normally (clipped floor)
|
| 82 |
+
|
| 83 |
+
**Why this shape:** Adam-relative normalization is scale-invariant — works the same on T0 quadratics (|f| ~ 10) and Rosenbrock (|f| ~ 1000) without hand-tuned per-landscape knobs. And crucially, **it does NOT require knowing `f_min`** — this design extends directly to neural-network training as Phase D (v3), where the global minimum of training loss is unknowable.
|
| 84 |
+
|
| 85 |
+
**Bonus fields in the reward breakdown:** `my_progress`, `adam_progress`, and `speedup_vs_adam` (= `my_progress / denom`) are logged alongside `r_regret` for diagnostics and human-readable leaderboards (e.g. "this optimizer is 10× faster than Adam on this landscape").
|
| 86 |
+
|
| 87 |
+
#### 2. `r_convergence` — speed bonus
|
| 88 |
+
|
| 89 |
+
**Measures:** how quickly the committed optimizer drops `f` below 1% of the initial value on the first arena seed.
|
| 90 |
+
|
| 91 |
+
**Computed:**
|
| 92 |
+
```
|
| 93 |
+
r_conv = clamp( 1 − convergence_step / N , 0, 1 ) if converged
|
| 94 |
+
= 0.0 if never reached 1%
|
| 95 |
+
```
|
| 96 |
+
where `N = ARENA_STEPS = 200` and `convergence_step` is the first `t` such that `f(x_t) < 0.01 · f(x_0)` on seed 101 (the first seed in `ARENA_SEEDS`).
|
| 97 |
+
|
| 98 |
+
**Range:** [0, 1]
|
| 99 |
+
- `1.0`: converged at step 0 (impossible; asymptotic)
|
| 100 |
+
- `0.5`: converged at step 100
|
| 101 |
+
- `0.0`: never converged within 200 steps
|
| 102 |
+
|
| 103 |
+
**Why:** distinguishes fast optimizers from slow ones among those that do converge. Without this, an optimizer that reaches the minimum in 50 steps and one that reaches it in 199 get identical `r_regret`.
|
| 104 |
+
|
| 105 |
+
**Only uses seed 101** — one seed's trajectory is enough to proxy speed; averaging across 10 would be more faithful but this is cheaper.
|
| 106 |
+
|
| 107 |
+
#### 3. `r_robustness` — cross-seed consistency
|
| 108 |
+
|
| 109 |
+
**Measures:** whether the optimizer achieves similar final values across all 10 arena seeds, or whether it's luck-of-the-init sensitive.
|
| 110 |
+
|
| 111 |
+
**Computed in `ArenaResult.robustness`:**
|
| 112 |
+
```
|
| 113 |
+
r_robust = clamp( 1 − std(final_values) / |mean(final_values)| , 0, 1 )
|
| 114 |
+
```
|
| 115 |
+
using only seeds that didn't crash. If mean ≈ 0, returns 1.0 when std is tiny else 0.0.
|
| 116 |
+
|
| 117 |
+
**Range:** [0, 1]
|
| 118 |
+
- `1.0`: all 10 seeds ended at essentially the same `f` value (tight distribution)
|
| 119 |
+
- `0.0`: huge variance across seeds (works on some inits, fails on others)
|
| 120 |
+
|
| 121 |
+
**Why:** anti-"works-only-from-favorable-init" defense (§11). An optimizer that converges cleanly on seed 101 but diverges on seed 505 has low r_robustness even if mean_regret is okay.
|
| 122 |
+
|
| 123 |
+
#### 4. `r_novelty` — structural departure from references
|
| 124 |
+
|
| 125 |
+
**Measures:** how structurally different the committed source is from the standard optimizers SGD/Adam/Momentum.
|
| 126 |
+
|
| 127 |
+
**Computed via `ast_novelty_score`:**
|
| 128 |
+
```
|
| 129 |
+
r_novelty = min over ref in {sgd, adam, momentum} of
|
| 130 |
+
1 − difflib.SequenceMatcher(committed, ref).ratio()
|
| 131 |
+
clamped to [0, 1]
|
| 132 |
+
```
|
| 133 |
+
Uses character-level diff ratio (difflib). `0.0` = byte-identical to one of the references. `1.0` = totally different strings. For reference, a tweaked-Adam with different hparams scores ~0.3; a genuinely different algorithm (line-search + trust region) scores ~0.7.
|
| 134 |
+
|
| 135 |
+
**Range:** [0, 1]
|
| 136 |
+
|
| 137 |
+
**Gate:** `r_novelty` is **only applied when `r_regret > 0.5`**. This prevents rewarding "novel AND broken" — you must beat Adam by a clear margin before creativity earns anything.
|
| 138 |
+
|
| 139 |
+
**Why:** prevents the model from just copying Adam after running `run_baseline("adam")`. Without this gate or term, the reward-maximizing strategy is "memorize Adam."
|
| 140 |
+
|
| 141 |
+
#### 5. `r_budget` — penalty for over-spending budget
|
| 142 |
+
|
| 143 |
+
**Measures:** what fraction of the action budget was used.
|
| 144 |
+
|
| 145 |
+
**Computed:**
|
| 146 |
+
```
|
| 147 |
+
r_budget = clamp( budget_spent / 12 , 0, 1 )
|
| 148 |
+
```
|
| 149 |
+
where `budget_spent` is the sum of per-action costs (baseline=2, draft=2, inspect=1, commit=0), NOT the count of actions.
|
| 150 |
+
|
| 151 |
+
**Range:** [0, 1]
|
| 152 |
+
- `0.0`: no budget used (impossible since at least one draft is needed)
|
| 153 |
+
- `1.0`: full budget consumed
|
| 154 |
+
|
| 155 |
+
**Why:** mild pressure toward efficiency. With `W_BUDGET = 0.05`, the swing between "committed at budget 4" and "exhausted at 12" is only 0.4 × 0.05 = 0.02 reward — deliberately small so it doesn't override algorithmic quality but is positive enough to discourage deliberate stalling.
|
| 156 |
+
|
| 157 |
+
#### 6. `r_eval_failures` — crash penalty
|
| 158 |
+
|
| 159 |
+
**Measures:** fraction of arena seeds where the committed optimizer raised a `SandboxError` (NaN output, wrong shape, timeout, Python error).
|
| 160 |
+
|
| 161 |
+
**Computed:**
|
| 162 |
+
```
|
| 163 |
+
r_eval_failures = sum(arena.crashed) / 10
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
**Range:** [0, 1]
|
| 167 |
+
- `0.0`: all 10 seeds ran to completion
|
| 168 |
+
- `1.0`: committed code crashes on every seed
|
| 169 |
+
|
| 170 |
+
**Why:** heavily weighted at `W_EVAL_FAIL = 0.5` so a uniformly-crashing commit scores at least −0.5 regardless of any other component. Prevents "commit broken code to avoid bad eval" gaming (§11).
|
| 171 |
+
|
| 172 |
+
### Concrete example
|
| 173 |
+
|
| 174 |
+
From the scripted test (quadratic dim=5, cond=44.4, SGD+momentum with lr=0.05 committed, progress-based reward, LR-tuned Adam baseline):
|
| 175 |
+
|
| 176 |
+
| Component | Value | Weighted contribution |
|
| 177 |
+
|---|---|---|
|
| 178 |
+
| `r_regret` | ~0 (my_progress ≈ adam_progress, both ≈10.51) | 0.000 |
|
| 179 |
+
| `r_convergence` | +0.835 | +0.251 |
|
| 180 |
+
| `r_robustness` | +0.5897 | +0.177 |
|
| 181 |
+
| `r_novelty` | 0 (gated; r_regret < 0.5) | 0.000 |
|
| 182 |
+
| `r_budget` | 0.583 (7/12 used) | −0.029 |
|
| 183 |
+
| `r_eval_failures` | 0.0 (no crashes) | 0.0 |
|
| 184 |
+
| **`r_total`** | — | **+0.398** |
|
| 185 |
+
|
| 186 |
+
Adam's tuned LR for this landscape came out to `0.03` — when LR is tuned, the committed SGD+momentum (lr=0.05) is essentially **tied** with Adam, and the reward correctly reflects that. Under the previous unfair baseline (Adam default lr=1e-3), the same draft would have scored 1.42. The ~1.0 reward swing reflects how much of the old "win" was just LR-tuning, not algorithmic merit.
|
| 187 |
+
|
| 188 |
+
### Stepwise feedback (NOT training reward)
|
| 189 |
+
|
| 190 |
+
Computed in `compute_step_reward`. Surfaced to the LLM via `obs.last_action_result["feedback"]` after each non-terminal turn. Explicitly NOT summed into `r_total`.
|
| 191 |
+
|
| 192 |
+
- `phi_delta`: change in `−best_auto_test_final_f / 10` across this turn. Positive means the newest draft improved the best auto-test result. The LLM sees this and knows "I just made progress."
|
| 193 |
+
- `compile_penalty`: literal `-0.1` marker emitted whenever the latest draft failed to compile. Purely a flag for the LLM's context.
|
| 194 |
+
|
| 195 |
+
These are communication channels, not reward. Keeping them out of the training scalar preserves the terminal-only robustness property while still giving the LLM something to react to mid-episode.
|
| 196 |
+
|
| 197 |
+
## Constants
|
| 198 |
+
|
| 199 |
+
| Constant | Value | Source |
|
| 200 |
+
|---|---|---|
|
| 201 |
+
| `BUDGET_TOTAL` | 12 | `server/landscapeforge_environment.py` |
|
| 202 |
+
| `ACTION_COSTS` | baseline=2, draft=2, inspect=1, commit=0 | `models.py` |
|
| 203 |
+
| `ARENA_SEEDS` | `[101, 202, ..., 1010]` (10 fresh seeds) | `server/landscapeforge_environment.py` |
|
| 204 |
+
| `ARENA_STEPS` | 200 | same |
|
| 205 |
+
| `BASELINE_STEPS` | 30 | same (fixed; agent cannot override) |
|
| 206 |
+
| Adam LR sweep grid | `{1e-4, 1e-3, 3e-3, 1e-2, 3e-2, 1e-1, 3e-1}` | `reference_optimizers.tune_adam_lr` |
|
| 207 |
+
| Adam LR sweep steps | 30 | same |
|
| 208 |
+
| Adam LR sweep init seed | 0 (not in ARENA_SEEDS) | `_ensure_adam_arena` in env |
|
| 209 |
+
| Draft auto-test init seed | 0 | `arena.auto_test_draft` |
|
| 210 |
+
| Draft auto-test steps | 20 | same |
|
| 211 |
+
| Init scale (seed-sampled `x0`) | `N(0, 0.5² I)` | `arena.run_arena` + `auto_test_draft` |
|
| 212 |
+
| Dim range per episode | 2–5 (v1) | `server/landscapeforge_environment.py` |
|
| 213 |
+
| Sandbox init timeout | 1.0 s | `sandbox.compile_optimizer` |
|
| 214 |
+
| Sandbox step timeout | 0.5 s | same |
|
| 215 |
+
| Reward weights | w_regret=1.0, w_conv=0.3, w_robust=0.3, w_novelty=0.1, w_budget=0.05, w_evalfail=0.5 | `rewards.py` |
|
| 216 |
+
| Novelty gate | Applied only when `r_regret > 0.5` | `rewards.NOVELTY_GATE` |
|
| 217 |
+
| `PHI_SCALE` (potential normalizer) | 10.0 | `rewards.py` |
|
| 218 |
+
| `COMPILE_PENALTY_SIGNAL` | -0.1 | `rewards.py` |
|
| 219 |
+
| Tier menus | T0: quadratic/styblinski_tang/huber · T1: +gaussian_mix/himmelblau · T2: +rosenbrock/stiff_quadratic/plateau/cliff | `landscapes.TIER_MENU` |
|
| 220 |
+
| Quadratic cond-number cap per tier | T0: 100, T1: 1000, T2: 10000 | `_sample_params` in env |
|
| 221 |
+
|
| 222 |
+
## Assumptions / simplifications (v1)
|
| 223 |
+
|
| 224 |
+
1. **LandscapeForge is a template picker**, not a free-form code author. The env internally samples (template, params) uniformly from the active tier's menu — no LandscapeForge LLM adapter in v1. Defers §18 v2 non-differentiability and gradient-source risks.
|
| 225 |
+
2. **All gradients are analytic**, hand-written per template. No autodiff/JAX, no finite differences. Templates are verified differentiable by construction.
|
| 226 |
+
2b. **Reward does NOT depend on `f_min`.** v0.2 switched from `r_regret = 1 − (my_regret / adam_regret)` (which required knowing the global minimum) to a progress-based form: `r_regret = clamp(my_progress / adam_progress − 1, −1, +1)` where `progress = f_initial − f_final` is observable per seed. `Landscape.f_min` is retained only for diagnostics, NOT used in training. This unlocks v3 NN extension (training loss has no knowable minimum).
|
| 227 |
+
3. **Only OptCoder has a policy.** The OpenEnv `Environment` here exposes the OptCoder side; LandscapeForge selection is internal.
|
| 228 |
+
4. **Single backbone assumption** (Qwen2.5-3B base + OptCoder LoRA) is in the design but not in code; training script is not yet implemented.
|
| 229 |
+
5. **Sandbox is in-process + SIGALRM timeout.** Works on main thread / CPython / POSIX. Known bug: HTTP `/step` via uvicorn returns 500 because SIGALRM only fires on the main thread; thread-based timeout fix is TODO.
|
| 230 |
+
6. **AST strip drops all module-level code except `class Optimizer`.** Imports are also dropped — the sandbox pre-injects `np` and `numpy` into globals, so submitted code can use `np.*` without an import line.
|
| 231 |
+
7. **Dim range 2–5** for v1 even though the design allows up to 100. Keeps arena eval fast (~30 ms/episode) and keeps the prompt token budget tight.
|
| 232 |
+
8. **Adam baseline for reward normalization is run inside the env** on every commit to compute `baseline_adam_regret`. Cost: one 200-step × 10-seed arena run per episode on top of the OptCoder eval. ~30 ms, acceptable.
|
| 233 |
+
9. **AST novelty score uses difflib** (character-level Levenshtein-ish) rather than true AST diff. Enough to detect "commit ≈ reference" but not semantically rigorous. Upgrade path noted.
|
| 234 |
+
10. **Tier advancement is not auto-wired.** `env.advance_tier(new_tier)` exists as a manual API; rolling-regret-based auto-advance is a trainer-side concern and not yet implemented.
|
| 235 |
+
|
| 236 |
+
## How to run
|
| 237 |
+
|
| 238 |
+
```bash
|
| 239 |
+
cd landscapeforge
|
| 240 |
+
uv sync # installs deps
|
| 241 |
+
uv run python tests/test_episode.py # 3 scripted episodes
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
Expected output: three `✓ PASSED` lines, final line `All tests passed.`
|
| 245 |
+
|
| 246 |
+
In-process usage (no server needed):
|
| 247 |
+
|
| 248 |
+
```python
|
| 249 |
+
from landscapeforge.models import LandscapeforgeAction
|
| 250 |
+
from landscapeforge.server.landscapeforge_environment import LandscapeforgeEnvironment
|
| 251 |
+
|
| 252 |
+
env = LandscapeforgeEnvironment(tier="T0", seed=42)
|
| 253 |
+
obs = env.reset()
|
| 254 |
+
obs = env.step(LandscapeforgeAction(kind="run_baseline", baseline_name="adam"))
|
| 255 |
+
obs = env.step(LandscapeforgeAction(kind="draft", code="...Optimizer class..."))
|
| 256 |
+
obs = env.step(LandscapeforgeAction(kind="commit"))
|
| 257 |
+
print(obs.reward, obs.r_optcoder_breakdown)
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
HTTP server starts with `uv run uvicorn landscapeforge.server.app:app`. `/reset` and `/schema` work; `/step` currently returns 500 (see assumption 5).
|
| 261 |
+
|
| 262 |
+
## Known gaps (tracked for next passes)
|
| 263 |
+
|
| 264 |
+
- SFT warm-start corpus: ~200 hand-authored `run_baseline → draft → inspect → draft → commit` traces (§15 Stage 0)
|
| 265 |
+
- GRPO training script using TRL + HF transformers
|
| 266 |
+
- Prompt renderer: format `obs` into the LLM prompt template from Appendix A
|
| 267 |
+
- Curriculum auto-advancement (rolling-mean-regret watchdog on top of `env.advance_tier`)
|
| 268 |
+
- Gradio demo Space with contour + trajectory animation
|
| 269 |
+
- Thread-based sandbox timeout to unblock HTTP `/step`
|
| 270 |
+
- True AST-diff-based novelty (replace difflib)
|
| 271 |
+
- Docker image + HF Spaces push
|
| 272 |
+
|
| 273 |
+
## Non-goals (v1)
|
| 274 |
+
|
| 275 |
+
- Free-form LandscapeForge code authoring (deferred to v2 per §18)
|
| 276 |
+
- Non-differentiable landscape defense (moot while LandscapeForge is template-picker)
|
| 277 |
+
- Multi-turn LandscapeForge-vs-OptCoder within a single episode (sequential only)
|
| 278 |
+
- Neural-net-as-landscape Phase-D (v3)
|
README.md
CHANGED
|
@@ -1,10 +1,151 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: LandscapeForge
|
| 3 |
+
emoji: 🏔️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
+
app_port: 8000
|
| 9 |
+
base_path: /web
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
+
- reinforcement-learning
|
| 13 |
+
- optimization
|
| 14 |
+
- llm-agents
|
| 15 |
+
- self-improvement
|
| 16 |
+
- gradio
|
| 17 |
+
license: apache-2.0
|
| 18 |
+
short_description: LLM agent designs optimizers via a probe-draft-commit REPL.
|
| 19 |
---
|
| 20 |
|
| 21 |
+
# 🏔️ LandscapeForge
|
| 22 |
+
|
| 23 |
+
**An OpenEnv where an LLM agent designs optimization algorithms through a probe-draft-commit REPL, trained against a Goldilocks-regulated landscape adversary.**
|
| 24 |
+
|
| 25 |
+
Target: **OpenEnv Hackathon, April 2026**. Theme 4 (Self-Improvement), secondary Theme 1 (Multi-Agent).
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## What this Space gives you
|
| 30 |
+
|
| 31 |
+
Two things, in one container:
|
| 32 |
+
|
| 33 |
+
| Path | What it is |
|
| 34 |
+
|---|---|
|
| 35 |
+
| **`/web`** | **Interactive Gradio demo** — landscape explorer + baseline race + paste-your-own-optimizer arena. Visual-first, meant to make the env legible to judges. |
|
| 36 |
+
| **`/reset`, `/step`, `/schema`, WebSocket** | **OpenEnv FastAPI endpoints** — wire the env into a TRL / Unsloth GRPO training loop. |
|
| 37 |
+
|
| 38 |
+
Same process, no second container required.
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## How the env works (in 90 seconds)
|
| 43 |
+
|
| 44 |
+
**OptCoder** is the LLM policy. Each episode:
|
| 45 |
+
|
| 46 |
+
1. **LandscapeForge** (an internal template picker in v1) chooses a loss landscape `f: ℝⁿ → ℝ` at a tier-appropriate difficulty: quadratic / Rosenbrock / Styblinski-Tang / Gaussian-mix / Himmelblau / plateau / cliff.
|
| 47 |
+
2. **OptCoder runs a 4-action REPL** with a budget of 12 units:
|
| 48 |
+
- `run_baseline(name)` — run SGD / Momentum / Adam / L-BFGS on the hidden landscape and see their trajectory (cost: 2)
|
| 49 |
+
- `draft(code)` — submit a full `Optimizer` class; the env auto-tests it for 20 steps (cost: 2)
|
| 50 |
+
- `inspect(draft_idx, step_range)` — zoom into a prior draft's per-step `(x, f, grad, update_norm, step_size_eff)` to diagnose failures (cost: 1)
|
| 51 |
+
- `commit` — evaluate the latest draft on the full **Phase-D arena**: 10 fresh seeds × 200 steps (cost: 0)
|
| 52 |
+
3. **Reward** (terminal only; stepwise is feedback-only):
|
| 53 |
+
- `r_regret` — **Adam-relative progress** (tuned Adam LR per landscape; no `f_min` dependency, generalises directly to NN training)
|
| 54 |
+
- `r_convergence`, `r_robustness`, `r_novelty` (gated), minus `r_budget`, minus `r_eval_failures`
|
| 55 |
+
4. **GRPO** can then train the policy; arena wall-clock is ~50 ms so ~36k episodes/hour on one H100.
|
| 56 |
+
|
| 57 |
+
See `IMPLEMENTATION.md` and `LANDSCAPEFORGE_DESIGN.md` in this repo for the full spec, staged bootstrap (SFT → solo RL → adversarial unfreezing), and the anti-reward-hacking table.
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
## Quick-start (Python client)
|
| 62 |
+
|
| 63 |
+
```python
|
| 64 |
+
from landscapeforge import LandscapeforgeEnv, LandscapeforgeAction
|
| 65 |
+
|
| 66 |
+
with LandscapeforgeEnv.from_docker_image("landscapeforge-env:latest") as env:
|
| 67 |
+
obs = env.reset()
|
| 68 |
+
env.step(LandscapeforgeAction(kind="run_baseline", baseline_name="adam"))
|
| 69 |
+
env.step(LandscapeforgeAction(kind="draft", code="""
|
| 70 |
+
class Optimizer:
|
| 71 |
+
def __init__(self, dim):
|
| 72 |
+
self.lr = 0.05; self.beta = 0.9
|
| 73 |
+
self.v = np.zeros(dim)
|
| 74 |
+
def step(self, x, f_val, grad):
|
| 75 |
+
self.v = self.beta * self.v - self.lr * grad
|
| 76 |
+
return x + self.v
|
| 77 |
+
"""))
|
| 78 |
+
result = env.step(LandscapeforgeAction(kind="commit"))
|
| 79 |
+
print(result.observation.r_optcoder_breakdown)
|
| 80 |
+
# {'r_regret': ..., 'r_convergence': ..., 'r_robustness': ...,
|
| 81 |
+
# 'r_novelty': ..., 'r_budget': ..., 'r_eval_failures': ...,
|
| 82 |
+
# 'my_progress': ..., 'adam_progress': ..., 'speedup_vs_adam': ...}
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
## Quick-start (drive with any OpenAI-compat LLM)
|
| 86 |
+
|
| 87 |
+
The repo ships `run_llm_episode.py` that drives one episode against any `/v1/chat/completions` endpoint (HuggingFace router, Ollama, vLLM, …):
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
# Ollama local
|
| 91 |
+
API_BASE_URL=http://localhost:11434/v1 MODEL_NAME=qwen2.5:3b \
|
| 92 |
+
python -m landscapeforge.run_llm_episode
|
| 93 |
+
|
| 94 |
+
# HuggingFace router
|
| 95 |
+
HF_TOKEN=hf_xxx MODEL_NAME=Qwen/Qwen2.5-7B-Instruct \
|
| 96 |
+
python -m landscapeforge.run_llm_episode
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
Full turn transcripts (prompt, raw reply, parsed action, env feedback, reward breakdown) are written to `episode_logs/*.jsonl` + `*.md`.
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## What to click first in `/web`
|
| 104 |
+
|
| 105 |
+
1. **Baseline Race** tab → pick Rosenbrock → hit "🏁 Race!" to see how default-SGD, default-Momentum, **tuned-Adam**, and crude-L-BFGS actually perform on the classic stiff valley.
|
| 106 |
+
2. **Optimizer Arena** tab → keep the sample SGD+Momentum optimizer, hit "⚔️ Run arena" to see the reward breakdown vs tuned Adam.
|
| 107 |
+
3. **Landscape Explorer** tab → browse the 9 template families with contour plots + structural hints.
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
|
| 111 |
+
## Repo structure
|
| 112 |
+
|
| 113 |
+
```
|
| 114 |
+
landscapeforge/
|
| 115 |
+
├── LANDSCAPEFORGE_DESIGN.md # Full design doc (v0.2)
|
| 116 |
+
├── IMPLEMENTATION.md # What's in the code today + constants
|
| 117 |
+
├── models.py # Action + Observation (pydantic)
|
| 118 |
+
├── landscapes.py # 9 analytic template builders with gradients
|
| 119 |
+
├── reference_optimizers.py # SGD / Momentum / Adam / L-BFGS + LR tuner
|
| 120 |
+
├── sandbox.py # AST-strip + restricted exec + timeout
|
| 121 |
+
├── arena.py # Phase-D runner + auto_test_draft
|
| 122 |
+
├── rewards.py # Terminal reward + stepwise feedback
|
| 123 |
+
├── prompts.py # obs → prompt / response → action
|
| 124 |
+
├── run_llm_episode.py # LLM-in-the-loop runner (OpenAI-compat)
|
| 125 |
+
├── server/
|
| 126 |
+
│ ├── app.py # FastAPI + mounted Gradio at /web
|
| 127 |
+
│ └── landscapeforge_environment.py # OpenEnv Environment class
|
| 128 |
+
├── demo/ui.py # Gradio UI source
|
| 129 |
+
├── tests/test_episode.py # Scripted end-to-end tests
|
| 130 |
+
└── episode_logs/ # Per-episode JSONL + Markdown transcripts
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## Research anchors
|
| 136 |
+
|
| 137 |
+
LandscapeForge sits at the intersection of five established research threads:
|
| 138 |
+
|
| 139 |
+
- **Thread 1** — LLMs as optimizer designers: [Lion (NeurIPS 2023)](https://arxiv.org/abs/2302.06675), [FunSearch (Nature 2024)](https://www.nature.com/articles/s41586-023-06924-6)
|
| 140 |
+
- **Thread 2** — Adversarial / co-evolutionary LLM-env: Coevolve, [GenEnv (ICLR 2026)](https://arxiv.org/html/2512.19682v1)
|
| 141 |
+
- **Thread 3** — Iterative code refinement: [Self-Refine](https://arxiv.org/abs/2303.17651)
|
| 142 |
+
- **Thread 4** — GRPO with measurable rewards: [HPC GFLOPS reward paper](https://arxiv.org/abs/2602.12049v1)
|
| 143 |
+
- **Thread 5** — Analytical landscape benchmarks: [BBOB/COCO](https://inria.hal.science/hal-00362649/document), [POET](https://arxiv.org/abs/1901.01753)
|
| 144 |
+
|
| 145 |
+
Every ingredient has prior work; the combination — LLM-generated optimizers + LLM-picked landscapes + iterative REPL + GRPO on Adam-relative progress — is novel.
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
## License
|
| 150 |
+
|
| 151 |
+
Apache-2.0.
|
__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Landscapeforge Environment."""
|
| 8 |
+
|
| 9 |
+
from .client import LandscapeforgeEnv
|
| 10 |
+
from .models import LandscapeforgeAction, LandscapeforgeObservation
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"LandscapeforgeAction",
|
| 14 |
+
"LandscapeforgeObservation",
|
| 15 |
+
"LandscapeforgeEnv",
|
| 16 |
+
]
|
arena.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase-D runner: run a compiled optimizer for N steps from K fresh seeds.
|
| 2 |
+
|
| 3 |
+
Computes per-run final regret and aggregate stats used by the reward module.
|
| 4 |
+
Also handles auto-test during draft actions (single fixed seed, fewer steps).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from .landscapes import Landscape
|
| 12 |
+
from .sandbox import CompiledOptimizer, SandboxError
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class ArenaResult:
|
| 17 |
+
initial_values: list[float] # per-seed f(x_0)
|
| 18 |
+
final_values: list[float] # per-seed f(x_N); NaN if crashed
|
| 19 |
+
crashed: list[bool] # per-seed
|
| 20 |
+
trajectories: list[list[dict]] # per-seed trajectories (may be empty)
|
| 21 |
+
|
| 22 |
+
@property
|
| 23 |
+
def mean_progress(self) -> float:
|
| 24 |
+
"""Mean descent: f_initial - f_final, averaged across non-crashed seeds.
|
| 25 |
+
Positive = optimizer descended; 0 = stayed put; negative = went uphill.
|
| 26 |
+
Crashed seeds count as 0 progress (conservative).
|
| 27 |
+
"""
|
| 28 |
+
prog: list[float] = []
|
| 29 |
+
for init, fin, crashed in zip(self.initial_values, self.final_values,
|
| 30 |
+
self.crashed):
|
| 31 |
+
if crashed or not np.isfinite(fin):
|
| 32 |
+
prog.append(0.0)
|
| 33 |
+
else:
|
| 34 |
+
prog.append(init - fin)
|
| 35 |
+
return float(np.mean(prog)) if prog else 0.0
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def mean_initial_scale(self) -> float:
|
| 39 |
+
"""|mean initial f|; used to establish a denominator floor when Adam
|
| 40 |
+
itself makes near-zero progress (rare but possible on plateaus)."""
|
| 41 |
+
vals = [abs(v) for v in self.initial_values if np.isfinite(v)]
|
| 42 |
+
return float(np.mean(vals)) if vals else 1.0
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def crash_fraction(self) -> float:
|
| 46 |
+
return float(np.mean(self.crashed)) if self.crashed else 0.0
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def robustness(self) -> float:
|
| 50 |
+
"""1 - std/|mean|, clamped to [0, 1]. High = consistent across seeds."""
|
| 51 |
+
vals = [v for v in self.final_values if np.isfinite(v)]
|
| 52 |
+
if len(vals) < 2:
|
| 53 |
+
return 0.0
|
| 54 |
+
m = np.mean(vals)
|
| 55 |
+
s = np.std(vals)
|
| 56 |
+
if abs(m) < 1e-9:
|
| 57 |
+
return 1.0 if s < 1e-6 else 0.0
|
| 58 |
+
return float(np.clip(1.0 - s / (abs(m) + 1e-9), 0.0, 1.0))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def run_arena(optimizer: CompiledOptimizer, ls: Landscape,
|
| 62 |
+
seeds: list[int], steps: int = 200,
|
| 63 |
+
init_scale: float = 0.5) -> ArenaResult:
|
| 64 |
+
"""Run the compiled optimizer from fresh seeds; capture per-run metrics.
|
| 65 |
+
|
| 66 |
+
Does NOT depend on `ls.f_min` — per-seed progress is `f_initial - f_final`,
|
| 67 |
+
which is observable regardless of whether the global minimum is known.
|
| 68 |
+
"""
|
| 69 |
+
initials, finals, crashed, trajs = [], [], [], []
|
| 70 |
+
for seed in seeds:
|
| 71 |
+
rng = np.random.default_rng(seed)
|
| 72 |
+
x = rng.normal(0.0, init_scale, size=ls.dim)
|
| 73 |
+
f0 = float(ls.f(x))
|
| 74 |
+
initials.append(f0)
|
| 75 |
+
traj: list[dict] = []
|
| 76 |
+
did_crash = False
|
| 77 |
+
try:
|
| 78 |
+
for t in range(steps):
|
| 79 |
+
fv = float(ls.f(x))
|
| 80 |
+
g = np.asarray(ls.grad(x), dtype=float)
|
| 81 |
+
traj.append({"t": t, "x": x.tolist(), "f": fv})
|
| 82 |
+
x = optimizer.step(x, fv, g)
|
| 83 |
+
except SandboxError:
|
| 84 |
+
did_crash = True
|
| 85 |
+
|
| 86 |
+
if did_crash:
|
| 87 |
+
finals.append(float("nan"))
|
| 88 |
+
else:
|
| 89 |
+
finals.append(float(ls.f(x)))
|
| 90 |
+
crashed.append(did_crash)
|
| 91 |
+
trajs.append(traj)
|
| 92 |
+
|
| 93 |
+
return ArenaResult(
|
| 94 |
+
initial_values=initials,
|
| 95 |
+
final_values=finals,
|
| 96 |
+
crashed=crashed,
|
| 97 |
+
trajectories=trajs,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def auto_test_draft(optimizer: CompiledOptimizer, ls: Landscape,
|
| 102 |
+
seed: int = 0, steps: int = 20, init_scale: float = 0.5) -> dict:
|
| 103 |
+
"""Single-seed quick test used at draft() time.
|
| 104 |
+
|
| 105 |
+
Returns a lightweight summary (not the full trajectory) plus a detailed
|
| 106 |
+
per-step record that `inspect` can later dig into.
|
| 107 |
+
"""
|
| 108 |
+
rng = np.random.default_rng(seed)
|
| 109 |
+
x = rng.normal(0.0, init_scale, size=ls.dim)
|
| 110 |
+
x0 = x.copy()
|
| 111 |
+
detail: list[dict] = []
|
| 112 |
+
diverged = False
|
| 113 |
+
err: str | None = None
|
| 114 |
+
try:
|
| 115 |
+
for t in range(steps):
|
| 116 |
+
fv = float(ls.f(x))
|
| 117 |
+
g = np.asarray(ls.grad(x), dtype=float)
|
| 118 |
+
gn = float(np.linalg.norm(g))
|
| 119 |
+
prev_x = x.copy()
|
| 120 |
+
x = optimizer.step(x, fv, g)
|
| 121 |
+
update_norm = float(np.linalg.norm(x - prev_x))
|
| 122 |
+
step_size = update_norm / (gn + 1e-12)
|
| 123 |
+
detail.append({
|
| 124 |
+
"t": t, "x": x.tolist(), "f": float(ls.f(x)),
|
| 125 |
+
"grad_norm": gn, "update_norm": update_norm, "step_size_eff": step_size,
|
| 126 |
+
})
|
| 127 |
+
except SandboxError as e:
|
| 128 |
+
diverged = True
|
| 129 |
+
err = str(e)
|
| 130 |
+
|
| 131 |
+
if diverged or not detail:
|
| 132 |
+
summary = {
|
| 133 |
+
"converged": False, "diverged": True, "error": err,
|
| 134 |
+
"final_f": None, "initial_f": float(ls.f(x0)),
|
| 135 |
+
"step_of_min": None, "min_f": None,
|
| 136 |
+
}
|
| 137 |
+
else:
|
| 138 |
+
fs = [d["f"] for d in detail]
|
| 139 |
+
step_of_min = int(np.argmin(fs))
|
| 140 |
+
summary = {
|
| 141 |
+
"converged": bool(fs[-1] < 0.1 * ls.f(x0)),
|
| 142 |
+
"diverged": False, "error": None,
|
| 143 |
+
"final_f": fs[-1], "initial_f": float(ls.f(x0)),
|
| 144 |
+
"step_of_min": step_of_min, "min_f": min(fs),
|
| 145 |
+
}
|
| 146 |
+
return {"summary": summary, "detail": detail}
|
client.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LandscapeForge Environment Client."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict
|
| 4 |
+
|
| 5 |
+
from openenv.core import EnvClient
|
| 6 |
+
from openenv.core.client_types import StepResult
|
| 7 |
+
from openenv.core.env_server.types import State
|
| 8 |
+
|
| 9 |
+
from .models import LandscapeforgeAction, LandscapeforgeObservation
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class LandscapeforgeEnv(
|
| 13 |
+
EnvClient[LandscapeforgeAction, LandscapeforgeObservation, State]
|
| 14 |
+
):
|
| 15 |
+
"""Client for the LandscapeForge OptCoder environment."""
|
| 16 |
+
|
| 17 |
+
def _step_payload(self, action: LandscapeforgeAction) -> Dict:
|
| 18 |
+
return action.model_dump(exclude_none=False)
|
| 19 |
+
|
| 20 |
+
def _parse_result(self, payload: Dict) -> StepResult[LandscapeforgeObservation]:
|
| 21 |
+
obs_data = payload.get("observation", {}) or {}
|
| 22 |
+
observation = LandscapeforgeObservation(**obs_data)
|
| 23 |
+
return StepResult(
|
| 24 |
+
observation=observation,
|
| 25 |
+
reward=payload.get("reward"),
|
| 26 |
+
done=payload.get("done", False),
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
def _parse_state(self, payload: Dict) -> State:
|
| 30 |
+
return State(
|
| 31 |
+
episode_id=payload.get("episode_id"),
|
| 32 |
+
step_count=payload.get("step_count", 0),
|
| 33 |
+
)
|
demo/__init__.py
ADDED
|
File without changes
|
demo/ui.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio demo for LandscapeForge.
|
| 2 |
+
|
| 3 |
+
Three tabs:
|
| 4 |
+
1. Landscape Explorer — pick a template, see 2D contour + structural hints.
|
| 5 |
+
2. Baseline Race — run all 4 reference optimizers on one landscape,
|
| 6 |
+
see the trajectories racing to the minimum.
|
| 7 |
+
3. Optimizer Arena — paste a custom Optimizer class, run it through
|
| 8 |
+
the full arena, see reward + trajectory vs Adam.
|
| 9 |
+
|
| 10 |
+
The Gradio app is exposed at `/web` of the OpenEnv FastAPI server when
|
| 11 |
+
ENABLE_WEB_INTERFACE=true and the env's app.py wires this module in via
|
| 12 |
+
create_app(..., gradio_builder=build_ui).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import io
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
import gradio as gr
|
| 21 |
+
import matplotlib
|
| 22 |
+
import matplotlib.pyplot as plt
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
matplotlib.use("Agg") # headless backend for Spaces
|
| 26 |
+
|
| 27 |
+
from ..arena import auto_test_draft, run_arena
|
| 28 |
+
from ..landscapes import BUILDERS, build_landscape, structural_hints
|
| 29 |
+
from ..reference_optimizers import run_baseline, tune_adam_lr
|
| 30 |
+
from ..rewards import ast_novelty_score, compute_optcoder_reward
|
| 31 |
+
from ..sandbox import SandboxError, compile_optimizer
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ---------- plotting helpers ----------
|
| 35 |
+
|
| 36 |
+
TEMPLATES_2D_SAFE = ["quadratic", "rosenbrock", "styblinski_tang", "huber",
|
| 37 |
+
"gaussian_mix", "himmelblau", "plateau", "cliff"]
|
| 38 |
+
BASELINE_COLORS = {
|
| 39 |
+
"sgd": "#ef4444", # red
|
| 40 |
+
"momentum": "#f59e0b", # amber
|
| 41 |
+
"adam": "#10b981", # green
|
| 42 |
+
"lbfgs": "#3b82f6", # blue
|
| 43 |
+
"custom": "#a855f7", # purple (for user drafts)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _contour_plot(ls, trajectories: dict[str, list[tuple[float, float]]] | None = None,
|
| 48 |
+
title: str | None = None):
|
| 49 |
+
"""Render a 2D contour with optional trajectories overlaid.
|
| 50 |
+
|
| 51 |
+
trajectories: {name: [(x0, x1), (x0, x1), ...]} — positions per step.
|
| 52 |
+
"""
|
| 53 |
+
assert ls.dim == 2, "contour plot requires dim=2"
|
| 54 |
+
|
| 55 |
+
fig, ax = plt.subplots(figsize=(6.5, 5.5))
|
| 56 |
+
|
| 57 |
+
# Auto-range the plot around the trajectories + origin
|
| 58 |
+
xs_all: list[float] = [0.0]
|
| 59 |
+
ys_all: list[float] = [0.0]
|
| 60 |
+
for traj in (trajectories or {}).values():
|
| 61 |
+
for pt in traj:
|
| 62 |
+
xs_all.append(pt[0]); ys_all.append(pt[1])
|
| 63 |
+
x_min = min(xs_all) - 1.5; x_max = max(xs_all) + 1.5
|
| 64 |
+
y_min = min(ys_all) - 1.5; y_max = max(ys_all) + 1.5
|
| 65 |
+
x_min = min(x_min, -3.5); x_max = max(x_max, 3.5)
|
| 66 |
+
y_min = min(y_min, -3.5); y_max = max(y_max, 3.5)
|
| 67 |
+
|
| 68 |
+
# Eval f on a grid
|
| 69 |
+
g = 60
|
| 70 |
+
xs = np.linspace(x_min, x_max, g)
|
| 71 |
+
ys = np.linspace(y_min, y_max, g)
|
| 72 |
+
X, Y = np.meshgrid(xs, ys)
|
| 73 |
+
Z = np.empty_like(X)
|
| 74 |
+
for i in range(g):
|
| 75 |
+
for j in range(g):
|
| 76 |
+
Z[i, j] = ls.f(np.array([X[i, j], Y[i, j]]))
|
| 77 |
+
|
| 78 |
+
# Robust contour levels (percentile-based, avoids long-tail blowing out)
|
| 79 |
+
finite = Z[np.isfinite(Z)]
|
| 80 |
+
lo = np.percentile(finite, 2)
|
| 81 |
+
hi = np.percentile(finite, 95)
|
| 82 |
+
levels = np.linspace(lo, hi, 25)
|
| 83 |
+
cs = ax.contourf(X, Y, Z, levels=levels, cmap="viridis", alpha=0.9)
|
| 84 |
+
ax.contour(X, Y, Z, levels=levels[::4], colors="white",
|
| 85 |
+
alpha=0.3, linewidths=0.5)
|
| 86 |
+
fig.colorbar(cs, ax=ax, shrink=0.85, label="f(x)")
|
| 87 |
+
|
| 88 |
+
# Overlay trajectories
|
| 89 |
+
if trajectories:
|
| 90 |
+
for name, traj in trajectories.items():
|
| 91 |
+
if not traj:
|
| 92 |
+
continue
|
| 93 |
+
color = BASELINE_COLORS.get(name, "#ffffff")
|
| 94 |
+
arr = np.array(traj)
|
| 95 |
+
ax.plot(arr[:, 0], arr[:, 1], color=color, linewidth=2.0,
|
| 96 |
+
alpha=0.95, label=name, zorder=5)
|
| 97 |
+
ax.scatter(arr[0:1, 0], arr[0:1, 1], color=color,
|
| 98 |
+
marker="o", s=55, edgecolors="white", linewidths=1.2,
|
| 99 |
+
zorder=6)
|
| 100 |
+
ax.scatter(arr[-1:, 0], arr[-1:, 1], color=color,
|
| 101 |
+
marker="*", s=150, edgecolors="white", linewidths=1.2,
|
| 102 |
+
zorder=7)
|
| 103 |
+
ax.legend(loc="upper left", framealpha=0.9)
|
| 104 |
+
|
| 105 |
+
ax.set_xlabel("x₁"); ax.set_ylabel("x₂")
|
| 106 |
+
ax.set_title(title or f"{ls.name} (dim=2)")
|
| 107 |
+
fig.tight_layout()
|
| 108 |
+
return fig
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _loss_curves_plot(traj_map: dict[str, list[float]], title: str):
|
| 112 |
+
"""f-vs-step line plot for each optimizer."""
|
| 113 |
+
fig, ax = plt.subplots(figsize=(7, 4.5))
|
| 114 |
+
for name, fs in traj_map.items():
|
| 115 |
+
if not fs:
|
| 116 |
+
continue
|
| 117 |
+
color = BASELINE_COLORS.get(name, "#ffffff")
|
| 118 |
+
ax.plot(range(len(fs)), fs, color=color, linewidth=2.0,
|
| 119 |
+
alpha=0.9, label=name)
|
| 120 |
+
ax.set_yscale("symlog", linthresh=1.0)
|
| 121 |
+
ax.set_xlabel("step"); ax.set_ylabel("f(x) (symlog)")
|
| 122 |
+
ax.set_title(title)
|
| 123 |
+
ax.grid(alpha=0.3)
|
| 124 |
+
ax.legend(loc="upper right", framealpha=0.9)
|
| 125 |
+
fig.tight_layout()
|
| 126 |
+
return fig
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _bar_plot(values: dict[str, float], title: str, ylabel: str,
|
| 130 |
+
invert: bool = False):
|
| 131 |
+
"""Simple bar chart for final-f comparison."""
|
| 132 |
+
fig, ax = plt.subplots(figsize=(6, 3.2))
|
| 133 |
+
names = list(values.keys())
|
| 134 |
+
vs = [values[n] for n in names]
|
| 135 |
+
colors = [BASELINE_COLORS.get(n, "#9ca3af") for n in names]
|
| 136 |
+
bars = ax.bar(names, vs, color=colors)
|
| 137 |
+
for bar, v in zip(bars, vs):
|
| 138 |
+
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(),
|
| 139 |
+
f"{v:.3g}", ha="center", va="bottom", fontsize=9)
|
| 140 |
+
ax.set_ylabel(ylabel)
|
| 141 |
+
ax.set_title(title)
|
| 142 |
+
if invert:
|
| 143 |
+
ax.invert_yaxis()
|
| 144 |
+
ax.grid(alpha=0.3, axis="y")
|
| 145 |
+
fig.tight_layout()
|
| 146 |
+
return fig
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ---------- tab 1: Landscape Explorer ----------
|
| 150 |
+
|
| 151 |
+
def _explore_landscape(template: str, dim: int, seed: int):
|
| 152 |
+
"""Build the landscape, return a contour (if 2D) + structural hints table."""
|
| 153 |
+
rng = np.random.default_rng(seed)
|
| 154 |
+
# For templates that need random centers (gaussian_mix), pass rng.
|
| 155 |
+
params: dict[str, Any] = {}
|
| 156 |
+
if template == "quadratic":
|
| 157 |
+
params = {"cond": 10.0}
|
| 158 |
+
if template == "gaussian_mix":
|
| 159 |
+
params = {"k": 3, "sigma": 0.5, "spread": 2.0}
|
| 160 |
+
if template == "himmelblau":
|
| 161 |
+
dim = 2
|
| 162 |
+
|
| 163 |
+
ls = build_landscape(template=template, dim=dim, params=params, rng=rng)
|
| 164 |
+
hints = structural_hints(ls, rng=rng)
|
| 165 |
+
|
| 166 |
+
# 2D contour when possible; otherwise a "not 2D" placeholder
|
| 167 |
+
if ls.dim == 2:
|
| 168 |
+
fig = _contour_plot(ls, title=f"{template} (dim=2)")
|
| 169 |
+
else:
|
| 170 |
+
fig, ax = plt.subplots(figsize=(6.5, 5.5))
|
| 171 |
+
ax.text(0.5, 0.5, f"{template} · dim={ls.dim}\n\nContour view is only rendered\nfor 2D landscapes.",
|
| 172 |
+
ha="center", va="center", fontsize=12,
|
| 173 |
+
color="#6b7280", transform=ax.transAxes)
|
| 174 |
+
ax.set_axis_off()
|
| 175 |
+
fig.tight_layout()
|
| 176 |
+
|
| 177 |
+
hints_rows = [[k, f"{v}" if not isinstance(v, float) else f"{v:.4g}"]
|
| 178 |
+
for k, v in hints.items()]
|
| 179 |
+
hints_rows.append(["dim", ls.dim])
|
| 180 |
+
hints_rows.append(["f_min (known)", f"{ls.f_min:.4g}"])
|
| 181 |
+
hints_rows.append(["description", ls.description])
|
| 182 |
+
|
| 183 |
+
return fig, hints_rows
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ---------- tab 2: Baseline Race ----------
|
| 187 |
+
|
| 188 |
+
def _baseline_race(template: str, seed: int):
|
| 189 |
+
"""Run all 4 baselines + Adam-tuned from the same init; return contour + curves."""
|
| 190 |
+
rng = np.random.default_rng(seed)
|
| 191 |
+
params: dict[str, Any] = {}
|
| 192 |
+
dim = 2
|
| 193 |
+
if template == "quadratic":
|
| 194 |
+
params = {"cond": 10.0}
|
| 195 |
+
if template == "gaussian_mix":
|
| 196 |
+
params = {"k": 3, "sigma": 0.5, "spread": 2.0}
|
| 197 |
+
ls = build_landscape(template=template, dim=dim, params=params, rng=rng)
|
| 198 |
+
|
| 199 |
+
init_rng = np.random.default_rng(seed + 999)
|
| 200 |
+
x0 = init_rng.normal(0.0, 0.5, size=dim)
|
| 201 |
+
|
| 202 |
+
# Tune Adam LR per-landscape for fair comparison.
|
| 203 |
+
best_lr = tune_adam_lr(ls.f, ls.grad, x0, sweep_steps=30)
|
| 204 |
+
|
| 205 |
+
# Run each baseline; collect 2D positions for the contour + f-curves for the plot.
|
| 206 |
+
traj_2d: dict[str, list[tuple[float, float]]] = {}
|
| 207 |
+
curves: dict[str, list[float]] = {}
|
| 208 |
+
finals: dict[str, float] = {}
|
| 209 |
+
for name in ["sgd", "momentum", "adam", "lbfgs"]:
|
| 210 |
+
r = run_baseline(name, ls.f, ls.grad, x0, steps=50)
|
| 211 |
+
traj = [s for s in r["trajectory"] if s.get("x") is not None]
|
| 212 |
+
traj_2d[name] = [(s["x"][0], s["x"][1]) for s in traj]
|
| 213 |
+
curves[name] = [s["f"] for s in traj if s.get("f") is not None]
|
| 214 |
+
finals[name] = curves[name][-1] if curves[name] else float("inf")
|
| 215 |
+
|
| 216 |
+
# Also run Adam with the tuned LR to show it dominates default-Adam.
|
| 217 |
+
adam_tuned_src = (
|
| 218 |
+
"class Optimizer:\n"
|
| 219 |
+
" def __init__(self, dim):\n"
|
| 220 |
+
f" self.lr = {best_lr}\n"
|
| 221 |
+
" self.b1 = 0.9; self.b2 = 0.999; self.eps = 1e-8\n"
|
| 222 |
+
" self.m = np.zeros(dim); self.v = np.zeros(dim); self.t = 0\n"
|
| 223 |
+
" def step(self, x, f_val, g):\n"
|
| 224 |
+
" self.t += 1\n"
|
| 225 |
+
" self.m = self.b1*self.m + (1-self.b1)*g\n"
|
| 226 |
+
" self.v = self.b2*self.v + (1-self.b2)*g*g\n"
|
| 227 |
+
" mh = self.m/(1-self.b1**self.t); vh = self.v/(1-self.b2**self.t)\n"
|
| 228 |
+
" return x - self.lr * mh / (np.sqrt(vh) + self.eps)\n"
|
| 229 |
+
)
|
| 230 |
+
try:
|
| 231 |
+
tuned_opt = compile_optimizer(adam_tuned_src, dim=dim)
|
| 232 |
+
xt = x0.copy()
|
| 233 |
+
traj_tuned = [(xt[0], xt[1])]
|
| 234 |
+
curve_tuned = [float(ls.f(xt))]
|
| 235 |
+
for _ in range(50):
|
| 236 |
+
g = np.asarray(ls.grad(xt), dtype=float)
|
| 237 |
+
xt = tuned_opt.step(xt, float(ls.f(xt)), g)
|
| 238 |
+
traj_tuned.append((xt[0], xt[1]))
|
| 239 |
+
curve_tuned.append(float(ls.f(xt)))
|
| 240 |
+
label = f"adam(tuned lr={best_lr:g})"
|
| 241 |
+
traj_2d[label] = traj_tuned
|
| 242 |
+
curves[label] = curve_tuned
|
| 243 |
+
finals[label] = curve_tuned[-1]
|
| 244 |
+
except SandboxError:
|
| 245 |
+
pass
|
| 246 |
+
|
| 247 |
+
contour = _contour_plot(ls, trajectories=traj_2d,
|
| 248 |
+
title=f"{template} — baselines racing")
|
| 249 |
+
curves_plot = _loss_curves_plot(curves, f"f(x) vs step (log)")
|
| 250 |
+
finals_plot = _bar_plot(finals,
|
| 251 |
+
title="Final f after 50 steps (lower = better)",
|
| 252 |
+
ylabel="f", invert=False)
|
| 253 |
+
|
| 254 |
+
summary = (
|
| 255 |
+
f"**Landscape:** {ls.description}\n\n"
|
| 256 |
+
f"**Tuned Adam LR:** `{best_lr:g}` (swept over a 7-point grid per-landscape)\n\n"
|
| 257 |
+
f"**Best baseline** on this run: `{min(finals, key=finals.get)}` "
|
| 258 |
+
f"with final f = `{min(finals.values()):.4f}`"
|
| 259 |
+
)
|
| 260 |
+
return contour, curves_plot, finals_plot, summary
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# ---------- tab 3: Optimizer Arena ----------
|
| 264 |
+
|
| 265 |
+
SAMPLE_OPTIMIZER_CODE = """
|
| 266 |
+
class Optimizer:
|
| 267 |
+
def __init__(self, dim):
|
| 268 |
+
self.lr = 0.05
|
| 269 |
+
self.beta = 0.9
|
| 270 |
+
self.v = np.zeros(dim)
|
| 271 |
+
|
| 272 |
+
def step(self, x, f_val, grad):
|
| 273 |
+
# SGD with Nesterov-ish momentum
|
| 274 |
+
self.v = self.beta * self.v - self.lr * grad
|
| 275 |
+
return x + self.v
|
| 276 |
+
""".strip()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _arena_compare(template: str, dim: int, seed: int, code: str):
|
| 280 |
+
"""Run user-submitted optimizer + tuned-Adam on the full arena; return plots."""
|
| 281 |
+
rng = np.random.default_rng(seed)
|
| 282 |
+
params: dict[str, Any] = {}
|
| 283 |
+
if template == "quadratic":
|
| 284 |
+
params = {"cond": 10.0}
|
| 285 |
+
if template == "gaussian_mix":
|
| 286 |
+
params = {"k": 3, "sigma": 0.5, "spread": 2.0}
|
| 287 |
+
if template == "himmelblau":
|
| 288 |
+
dim = 2
|
| 289 |
+
|
| 290 |
+
ls = build_landscape(template=template, dim=dim, params=params, rng=rng)
|
| 291 |
+
|
| 292 |
+
# Tune Adam LR for the baseline
|
| 293 |
+
tune_x0 = np.random.default_rng(0).normal(0.0, 0.5, size=dim)
|
| 294 |
+
best_lr = tune_adam_lr(ls.f, ls.grad, tune_x0, sweep_steps=30)
|
| 295 |
+
|
| 296 |
+
# Compile user code
|
| 297 |
+
try:
|
| 298 |
+
opt = compile_optimizer(code, dim=dim)
|
| 299 |
+
except SandboxError as e:
|
| 300 |
+
return None, None, None, f"### ❌ Compile error\n\n```\n{e}\n```", {}
|
| 301 |
+
|
| 302 |
+
# Single-seed auto-test for quick trajectory view
|
| 303 |
+
test = auto_test_draft(opt, ls, seed=seed, steps=20)
|
| 304 |
+
|
| 305 |
+
# Full arena for user's optimizer
|
| 306 |
+
user_arena = run_arena(opt, ls, seeds=[101, 202, 303, 404, 505, 606, 707, 808, 909, 1010],
|
| 307 |
+
steps=200)
|
| 308 |
+
|
| 309 |
+
# Full arena for tuned Adam
|
| 310 |
+
ADAM_TEMPLATE = f"""
|
| 311 |
+
class Optimizer:
|
| 312 |
+
def __init__(self, dim):
|
| 313 |
+
self.lr={best_lr}; self.b1=0.9; self.b2=0.999; self.eps=1e-8
|
| 314 |
+
self.m = np.zeros(dim); self.v = np.zeros(dim); self.t = 0
|
| 315 |
+
def step(self, x, f_val, grad):
|
| 316 |
+
self.t += 1
|
| 317 |
+
self.m = self.b1*self.m + (1-self.b1)*grad
|
| 318 |
+
self.v = self.b2*self.v + (1-self.b2)*grad*grad
|
| 319 |
+
mh = self.m/(1-self.b1**self.t)
|
| 320 |
+
vh = self.v/(1-self.b2**self.t)
|
| 321 |
+
return x - self.lr * mh / (np.sqrt(vh) + self.eps)
|
| 322 |
+
""".strip()
|
| 323 |
+
adam_opt = compile_optimizer(ADAM_TEMPLATE, dim=dim)
|
| 324 |
+
adam_arena = run_arena(adam_opt, ls, seeds=[101, 202, 303, 404, 505, 606, 707, 808, 909, 1010],
|
| 325 |
+
steps=200)
|
| 326 |
+
|
| 327 |
+
reward = compute_optcoder_reward(
|
| 328 |
+
arena=user_arena,
|
| 329 |
+
adam_arena=adam_arena,
|
| 330 |
+
actions_used_cost=0, # not relevant outside an episode
|
| 331 |
+
budget_total=12,
|
| 332 |
+
novelty_score=ast_novelty_score(code, [ADAM_TEMPLATE]),
|
| 333 |
+
convergence_step=None,
|
| 334 |
+
arena_steps=200,
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
# 2D contour if applicable
|
| 338 |
+
if dim == 2:
|
| 339 |
+
user_traj = [(s["x"][0], s["x"][1]) for s in test["detail"]]
|
| 340 |
+
adam_run = run_baseline("adam", ls.f, ls.grad,
|
| 341 |
+
np.random.default_rng(seed).normal(0.0, 0.5, 2), steps=50)
|
| 342 |
+
adam_traj = [(s["x"][0], s["x"][1]) for s in adam_run["trajectory"]
|
| 343 |
+
if s.get("x") is not None]
|
| 344 |
+
contour = _contour_plot(
|
| 345 |
+
ls,
|
| 346 |
+
trajectories={"custom": user_traj, "adam": adam_traj},
|
| 347 |
+
title=f"{template} — your optimizer vs tuned Adam",
|
| 348 |
+
)
|
| 349 |
+
else:
|
| 350 |
+
fig, ax = plt.subplots(figsize=(6.5, 5.5))
|
| 351 |
+
ax.text(0.5, 0.5, f"{template} · dim={dim}\nContour view only for 2D",
|
| 352 |
+
ha="center", va="center", fontsize=12,
|
| 353 |
+
color="#6b7280", transform=ax.transAxes)
|
| 354 |
+
ax.set_axis_off()
|
| 355 |
+
contour = fig
|
| 356 |
+
|
| 357 |
+
# Progress bar plot
|
| 358 |
+
progress = {
|
| 359 |
+
"custom": user_arena.mean_progress,
|
| 360 |
+
"adam (tuned)": adam_arena.mean_progress,
|
| 361 |
+
}
|
| 362 |
+
progress_plot = _bar_plot(progress,
|
| 363 |
+
title="Arena mean progress (higher = better)",
|
| 364 |
+
ylabel="mean(f_initial - f_final) across 10 seeds")
|
| 365 |
+
|
| 366 |
+
# Reward breakdown plot
|
| 367 |
+
bk = reward.breakdown
|
| 368 |
+
components = {
|
| 369 |
+
"r_regret": bk["r_regret"],
|
| 370 |
+
"r_convergence": bk["r_convergence"],
|
| 371 |
+
"r_robustness": bk["r_robustness"],
|
| 372 |
+
"r_novelty": bk["r_novelty"],
|
| 373 |
+
"-r_budget": -bk["r_budget"],
|
| 374 |
+
"-r_eval_fail": -bk["r_eval_failures"],
|
| 375 |
+
}
|
| 376 |
+
fig, ax = plt.subplots(figsize=(7, 3.2))
|
| 377 |
+
colors = ["#10b981" if v >= 0 else "#ef4444" for v in components.values()]
|
| 378 |
+
bars = ax.bar(list(components.keys()), list(components.values()), color=colors)
|
| 379 |
+
for bar, v in zip(bars, components.values()):
|
| 380 |
+
ax.text(bar.get_x() + bar.get_width() / 2,
|
| 381 |
+
bar.get_height() + (0.02 if v >= 0 else -0.06),
|
| 382 |
+
f"{v:+.3f}", ha="center",
|
| 383 |
+
va="bottom" if v >= 0 else "top", fontsize=9)
|
| 384 |
+
ax.axhline(0, color="black", linewidth=0.5)
|
| 385 |
+
ax.set_title(f"Reward breakdown · total = {reward.r_total:+.3f}")
|
| 386 |
+
ax.grid(alpha=0.3, axis="y")
|
| 387 |
+
fig.tight_layout()
|
| 388 |
+
reward_plot = fig
|
| 389 |
+
|
| 390 |
+
summary = (
|
| 391 |
+
f"### Summary\n\n"
|
| 392 |
+
f"- **Your progress (mean over 10 seeds):** `{user_arena.mean_progress:.4g}`\n"
|
| 393 |
+
f"- **Tuned Adam's progress:** `{adam_arena.mean_progress:.4g}` (lr={best_lr:g})\n"
|
| 394 |
+
f"- **Speedup vs Adam:** `{bk.get('speedup_vs_adam', 0):.3g}×`\n"
|
| 395 |
+
f"- **Your crash fraction:** `{user_arena.crash_fraction:.0%}`\n"
|
| 396 |
+
f"- **Total reward:** `{reward.r_total:+.3f}`"
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
return contour, progress_plot, reward_plot, summary, bk
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
# ---------- top-level UI ----------
|
| 403 |
+
|
| 404 |
+
ABOUT_MD = """
|
| 405 |
+
# LandscapeForge
|
| 406 |
+
|
| 407 |
+
An OpenEnv environment where an LLM agent designs optimization algorithms
|
| 408 |
+
through a probe-draft-commit REPL. Two agents co-evolve: one writes
|
| 409 |
+
optimizer code, the other picks adversarial landscapes.
|
| 410 |
+
|
| 411 |
+
**How it works:**
|
| 412 |
+
|
| 413 |
+
1. LandscapeForge picks a loss landscape `f : ℝⁿ → ℝ` (quadratic, Rosenbrock,
|
| 414 |
+
Styblinski-Tang, …) at a difficulty tier calibrated to the agent's skill.
|
| 415 |
+
2. The OptCoder agent runs the REPL: `run_baseline` reference optimizers to
|
| 416 |
+
observe behaviour, `draft` candidate `Optimizer` classes (env auto-tests
|
| 417 |
+
them), `inspect` prior drafts to diagnose, `commit` when satisfied.
|
| 418 |
+
3. Phase D — full evaluation on 10 fresh seeds × 200 steps. Reward is
|
| 419 |
+
**Adam-relative progress** (no `f_min` dependency — generalizes to NNs).
|
| 420 |
+
4. GRPO trains both agents against each other, Goldilocks-regulated so
|
| 421 |
+
difficulty tracks skill.
|
| 422 |
+
|
| 423 |
+
**This demo** lets you explore the env:
|
| 424 |
+
- **Landscape Explorer** — pick a template, see what the agent sees.
|
| 425 |
+
- **Baseline Race** — see how SGD / Momentum / Adam (tuned) / L-BFGS
|
| 426 |
+
actually perform on each landscape.
|
| 427 |
+
- **Optimizer Arena** — paste a custom `Optimizer` class, run it through
|
| 428 |
+
the full arena, see the reward breakdown vs tuned Adam.
|
| 429 |
+
|
| 430 |
+
The full env is also available via the FastAPI endpoint at `/step`, `/reset`,
|
| 431 |
+
`/schema` — wire it into any TRL/Unsloth GRPO training loop.
|
| 432 |
+
|
| 433 |
+
**Links:** [Design doc](./) · [Paper anchors: Lion, FunSearch, GenEnv] ·
|
| 434 |
+
[Source]
|
| 435 |
+
"""
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def build_ui(*args, **kwargs) -> gr.Blocks:
|
| 439 |
+
"""Entry point consumed by `create_app(..., gradio_builder=build_ui)`.
|
| 440 |
+
|
| 441 |
+
Accepts any args/kwargs that OpenEnv forwards; ignores them since this
|
| 442 |
+
demo operates on its own in-process env instances.
|
| 443 |
+
"""
|
| 444 |
+
with gr.Blocks(title="LandscapeForge",
|
| 445 |
+
theme=gr.themes.Soft(primary_hue="violet"),
|
| 446 |
+
css=".gr-box{padding:1em;}") as app:
|
| 447 |
+
gr.Markdown("# 🏔️ LandscapeForge\n"
|
| 448 |
+
"**An LLM agent designing optimizers through a probe-draft-commit REPL.**")
|
| 449 |
+
|
| 450 |
+
with gr.Tabs():
|
| 451 |
+
# --- Tab 1 ---
|
| 452 |
+
with gr.Tab("🌄 Landscape Explorer"):
|
| 453 |
+
gr.Markdown("Pick a landscape template; see what structural "
|
| 454 |
+
"hints the env shows the OptCoder agent.")
|
| 455 |
+
with gr.Row():
|
| 456 |
+
with gr.Column(scale=1):
|
| 457 |
+
tmpl = gr.Dropdown(
|
| 458 |
+
choices=TEMPLATES_2D_SAFE,
|
| 459 |
+
value="rosenbrock",
|
| 460 |
+
label="Template",
|
| 461 |
+
)
|
| 462 |
+
dim = gr.Slider(2, 10, value=2, step=1, label="Dim")
|
| 463 |
+
seed = gr.Slider(0, 100, value=0, step=1, label="Seed")
|
| 464 |
+
go1 = gr.Button("Build landscape", variant="primary")
|
| 465 |
+
with gr.Column(scale=2):
|
| 466 |
+
plot1 = gr.Plot(label="Contour (2D only)")
|
| 467 |
+
hints1 = gr.Dataframe(
|
| 468 |
+
headers=["property", "value"],
|
| 469 |
+
datatype=["str", "str"],
|
| 470 |
+
label="Structural hints (shown to the agent at reset)",
|
| 471 |
+
wrap=True,
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
go1.click(_explore_landscape, [tmpl, dim, seed],
|
| 475 |
+
[plot1, hints1])
|
| 476 |
+
# Fire once on load so the UI isn't empty
|
| 477 |
+
app.load(_explore_landscape,
|
| 478 |
+
[gr.State("rosenbrock"), gr.State(2), gr.State(0)],
|
| 479 |
+
[plot1, hints1])
|
| 480 |
+
|
| 481 |
+
# --- Tab 2 ---
|
| 482 |
+
with gr.Tab("🏁 Baseline Race"):
|
| 483 |
+
gr.Markdown("Watch SGD / Momentum / Adam (tuned per-landscape) / "
|
| 484 |
+
"L-BFGS race to the minimum on the same initial point.")
|
| 485 |
+
with gr.Row():
|
| 486 |
+
tmpl2 = gr.Dropdown(
|
| 487 |
+
choices=TEMPLATES_2D_SAFE,
|
| 488 |
+
value="rosenbrock",
|
| 489 |
+
label="Template (dim=2 for contour)",
|
| 490 |
+
)
|
| 491 |
+
seed2 = gr.Slider(0, 100, value=1, step=1, label="Seed")
|
| 492 |
+
go2 = gr.Button("🏁 Race!", variant="primary")
|
| 493 |
+
with gr.Row():
|
| 494 |
+
plot2a = gr.Plot(label="Contour + trajectories")
|
| 495 |
+
with gr.Row():
|
| 496 |
+
plot2b = gr.Plot(label="f(x) vs step")
|
| 497 |
+
plot2c = gr.Plot(label="Final f (50 steps)")
|
| 498 |
+
summary2 = gr.Markdown()
|
| 499 |
+
|
| 500 |
+
go2.click(_baseline_race, [tmpl2, seed2],
|
| 501 |
+
[plot2a, plot2b, plot2c, summary2])
|
| 502 |
+
|
| 503 |
+
# --- Tab 3 ---
|
| 504 |
+
with gr.Tab("⚔️ Optimizer Arena"):
|
| 505 |
+
gr.Markdown(
|
| 506 |
+
"Paste (or edit the sample) an `Optimizer` class, and "
|
| 507 |
+
"we'll run it through the full Phase-D arena against "
|
| 508 |
+
"tuned-Adam on the chosen landscape. **No `import` needed — "
|
| 509 |
+
"`np` is pre-injected.**"
|
| 510 |
+
)
|
| 511 |
+
with gr.Row():
|
| 512 |
+
with gr.Column(scale=1):
|
| 513 |
+
tmpl3 = gr.Dropdown(
|
| 514 |
+
choices=list(BUILDERS.keys()),
|
| 515 |
+
value="quadratic",
|
| 516 |
+
label="Template",
|
| 517 |
+
)
|
| 518 |
+
dim3 = gr.Slider(2, 10, value=5, step=1, label="Dim")
|
| 519 |
+
seed3 = gr.Slider(0, 100, value=42, step=1, label="Seed")
|
| 520 |
+
go3 = gr.Button("⚔️ Run arena", variant="primary")
|
| 521 |
+
with gr.Column(scale=2):
|
| 522 |
+
code3 = gr.Code(
|
| 523 |
+
value=SAMPLE_OPTIMIZER_CODE,
|
| 524 |
+
language="python",
|
| 525 |
+
label="Your Optimizer class",
|
| 526 |
+
lines=14,
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
with gr.Row():
|
| 530 |
+
plot3a = gr.Plot(label="2D trajectory (if dim=2)")
|
| 531 |
+
plot3b = gr.Plot(label="Mean arena progress (higher = better)")
|
| 532 |
+
plot3c = gr.Plot(label="Reward breakdown vs tuned Adam")
|
| 533 |
+
summary3 = gr.Markdown()
|
| 534 |
+
breakdown3 = gr.JSON(label="Full reward breakdown")
|
| 535 |
+
|
| 536 |
+
go3.click(_arena_compare, [tmpl3, dim3, seed3, code3],
|
| 537 |
+
[plot3a, plot3b, plot3c, summary3, breakdown3])
|
| 538 |
+
|
| 539 |
+
# --- About ---
|
| 540 |
+
with gr.Tab("📖 About"):
|
| 541 |
+
gr.Markdown(ABOUT_MD)
|
| 542 |
+
|
| 543 |
+
return app
|
landscapes.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Landscape template library (v1 template-picker LandscapeForge).
|
| 2 |
+
|
| 3 |
+
Each template has a hand-written analytic gradient — no autodiff required.
|
| 4 |
+
All templates are guaranteed differentiable, finite, and bounded below on
|
| 5 |
+
the typical init region x ~ N(0, 0.5^2 I).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from typing import Callable, Literal
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
TemplateName = Literal[
|
| 15 |
+
"quadratic", "styblinski_tang", "huber",
|
| 16 |
+
"gaussian_mix", "himmelblau",
|
| 17 |
+
"rosenbrock", "stiff_quadratic", "plateau", "cliff",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
Tier = Literal["T0", "T1", "T2"]
|
| 21 |
+
|
| 22 |
+
TIER_MENU: dict[str, list[str]] = {
|
| 23 |
+
"T0": ["quadratic", "styblinski_tang", "huber"],
|
| 24 |
+
"T1": ["quadratic", "styblinski_tang", "huber", "gaussian_mix", "himmelblau"],
|
| 25 |
+
"T2": ["quadratic", "styblinski_tang", "huber", "gaussian_mix", "himmelblau",
|
| 26 |
+
"rosenbrock", "stiff_quadratic", "plateau", "cliff"],
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class Landscape:
|
| 32 |
+
name: str
|
| 33 |
+
dim: int
|
| 34 |
+
params: dict
|
| 35 |
+
f: Callable[[np.ndarray], float]
|
| 36 |
+
grad: Callable[[np.ndarray], np.ndarray]
|
| 37 |
+
f_min: float = 0.0 # known global minimum value
|
| 38 |
+
description: str = ""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ---------- template constructors ----------
|
| 42 |
+
|
| 43 |
+
def make_quadratic(dim: int, cond: float = 1.0, rng: np.random.Generator | None = None) -> Landscape:
|
| 44 |
+
"""f(x) = 0.5 * x^T A x with diag Hessian of condition number `cond`."""
|
| 45 |
+
diag = np.linspace(1.0, float(cond), dim)
|
| 46 |
+
A = np.diag(diag)
|
| 47 |
+
|
| 48 |
+
def f(x): return float(0.5 * x @ A @ x)
|
| 49 |
+
def grad(x): return A @ x
|
| 50 |
+
|
| 51 |
+
return Landscape(
|
| 52 |
+
name="quadratic", dim=dim, params={"cond": cond},
|
| 53 |
+
f=f, grad=grad, f_min=0.0,
|
| 54 |
+
description=f"Convex quadratic in R^{dim}, condition number {cond:.1f}.",
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def make_stiff_quadratic(dim: int, cond: float = 1000.0, **_) -> Landscape:
|
| 59 |
+
return make_quadratic(dim, cond) # alias with higher cond
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def make_styblinski_tang(dim: int, **_) -> Landscape:
|
| 63 |
+
"""f(x) = 0.5 * sum(x^4 - 16 x^2 + 5 x), min at x_i ≈ -2.903534."""
|
| 64 |
+
def f(x):
|
| 65 |
+
return float(0.5 * np.sum(x**4 - 16.0 * x**2 + 5.0 * x))
|
| 66 |
+
|
| 67 |
+
def grad(x):
|
| 68 |
+
return 0.5 * (4.0 * x**3 - 32.0 * x + 5.0)
|
| 69 |
+
|
| 70 |
+
f_min = dim * 0.5 * ((-2.903534)**4 - 16.0 * (-2.903534)**2 + 5.0 * (-2.903534))
|
| 71 |
+
return Landscape(
|
| 72 |
+
name="styblinski_tang", dim=dim, params={},
|
| 73 |
+
f=f, grad=grad, f_min=float(f_min),
|
| 74 |
+
description=f"Styblinski-Tang in R^{dim}, multimodal with global min at x_i ≈ -2.9.",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def make_huber(dim: int, delta: float = 1.0, **_) -> Landscape:
|
| 79 |
+
"""Smooth Huber-ish loss: f(x) = sum(delta^2 * (sqrt(1 + (x/delta)^2) - 1)).
|
| 80 |
+
|
| 81 |
+
Smooth everywhere (unlike piecewise Huber). Behaves like 0.5 x^2 near 0,
|
| 82 |
+
linear for |x| >> delta.
|
| 83 |
+
"""
|
| 84 |
+
def f(x):
|
| 85 |
+
return float(np.sum(delta**2 * (np.sqrt(1.0 + (x/delta)**2) - 1.0)))
|
| 86 |
+
|
| 87 |
+
def grad(x):
|
| 88 |
+
return x / np.sqrt(1.0 + (x/delta)**2)
|
| 89 |
+
|
| 90 |
+
return Landscape(
|
| 91 |
+
name="huber", dim=dim, params={"delta": delta},
|
| 92 |
+
f=f, grad=grad, f_min=0.0,
|
| 93 |
+
description=f"Smooth pseudo-Huber in R^{dim}, delta={delta}.",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def make_rosenbrock(dim: int, **_) -> Landscape:
|
| 98 |
+
"""Classic stiff-valley Rosenbrock."""
|
| 99 |
+
assert dim >= 2
|
| 100 |
+
|
| 101 |
+
def f(x):
|
| 102 |
+
return float(np.sum(100.0 * (x[1:] - x[:-1]**2)**2 + (1.0 - x[:-1])**2))
|
| 103 |
+
|
| 104 |
+
def grad(x):
|
| 105 |
+
g = np.zeros_like(x)
|
| 106 |
+
g[:-1] += -400.0 * x[:-1] * (x[1:] - x[:-1]**2) - 2.0 * (1.0 - x[:-1])
|
| 107 |
+
g[1:] += 200.0 * (x[1:] - x[:-1]**2)
|
| 108 |
+
return g
|
| 109 |
+
|
| 110 |
+
return Landscape(
|
| 111 |
+
name="rosenbrock", dim=dim, params={},
|
| 112 |
+
f=f, grad=grad, f_min=0.0,
|
| 113 |
+
description=f"Rosenbrock (curved stiff valley) in R^{dim}, min at (1,..,1).",
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def make_gaussian_mix(dim: int, k: int = 3, sigma: float = 0.5, spread: float = 2.0,
|
| 118 |
+
rng: np.random.Generator | None = None, **_) -> Landscape:
|
| 119 |
+
"""f(x) = -sum_j w_j * exp(-||x - c_j||^2 / (2 sigma^2)).
|
| 120 |
+
|
| 121 |
+
Negated so minima are where mixture density is highest. Bounded below by 0.
|
| 122 |
+
"""
|
| 123 |
+
rng = rng if rng is not None else np.random.default_rng(0)
|
| 124 |
+
centers = rng.normal(0.0, spread, size=(k, dim))
|
| 125 |
+
weights = np.ones(k) / k # uniform; one of these is the "global" min
|
| 126 |
+
s2 = sigma * sigma
|
| 127 |
+
|
| 128 |
+
def f(x):
|
| 129 |
+
d2 = np.sum((centers - x)**2, axis=1) # (k,)
|
| 130 |
+
return float(-np.sum(weights * np.exp(-d2 / (2.0 * s2))))
|
| 131 |
+
|
| 132 |
+
def grad(x):
|
| 133 |
+
diffs = x - centers # (k, dim)
|
| 134 |
+
d2 = np.sum(diffs**2, axis=1) # (k,)
|
| 135 |
+
e = np.exp(-d2 / (2.0 * s2)) # (k,)
|
| 136 |
+
# d/dx [-w_j exp(-||x-c_j||^2 / 2s^2)] = w_j * (x-c_j) / s^2 * exp(...)
|
| 137 |
+
return (weights * e / s2)[:, None] * diffs # broadcast, sum over k below
|
| 138 |
+
# Wait — need to sum over components:
|
| 139 |
+
|
| 140 |
+
# Fix grad to properly sum:
|
| 141 |
+
def grad_correct(x):
|
| 142 |
+
diffs = x - centers
|
| 143 |
+
d2 = np.sum(diffs**2, axis=1)
|
| 144 |
+
e = np.exp(-d2 / (2.0 * s2))
|
| 145 |
+
coeff = weights * e / s2
|
| 146 |
+
return np.sum(coeff[:, None] * diffs, axis=0)
|
| 147 |
+
|
| 148 |
+
# Global min approx: evaluate at each center, return the lowest f.
|
| 149 |
+
f_min = float(min(f(c) for c in centers))
|
| 150 |
+
return Landscape(
|
| 151 |
+
name="gaussian_mix", dim=dim,
|
| 152 |
+
params={"k": k, "sigma": sigma, "spread": spread, "centers": centers.tolist()},
|
| 153 |
+
f=f, grad=grad_correct, f_min=f_min,
|
| 154 |
+
description=f"Negative Gaussian mixture in R^{dim}, k={k} modes, sigma={sigma}, spread={spread}.",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def make_himmelblau(dim: int = 2, **_) -> Landscape:
|
| 159 |
+
"""f(x,y) = (x^2+y-11)^2 + (x+y^2-7)^2. 4 global minima at value 0."""
|
| 160 |
+
assert dim == 2, "Himmelblau is 2D only"
|
| 161 |
+
|
| 162 |
+
def f(x):
|
| 163 |
+
return float((x[0]**2 + x[1] - 11.0)**2 + (x[0] + x[1]**2 - 7.0)**2)
|
| 164 |
+
|
| 165 |
+
def grad(x):
|
| 166 |
+
gx = 4.0 * x[0] * (x[0]**2 + x[1] - 11.0) + 2.0 * (x[0] + x[1]**2 - 7.0)
|
| 167 |
+
gy = 2.0 * (x[0]**2 + x[1] - 11.0) + 4.0 * x[1] * (x[0] + x[1]**2 - 7.0)
|
| 168 |
+
return np.array([gx, gy])
|
| 169 |
+
|
| 170 |
+
return Landscape(
|
| 171 |
+
name="himmelblau", dim=2, params={}, f=f, grad=grad, f_min=0.0,
|
| 172 |
+
description="Himmelblau (2D), four global minima at value 0.",
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def make_plateau(dim: int, radius: float = 1.0, **_) -> Landscape:
|
| 177 |
+
"""Smooth plateau: f = tanh((||x||^2 - r^2) / r^2). Near-zero gradient far from boundary."""
|
| 178 |
+
r2 = radius * radius
|
| 179 |
+
|
| 180 |
+
def f(x):
|
| 181 |
+
return float(np.tanh((np.sum(x**2) - r2) / r2))
|
| 182 |
+
|
| 183 |
+
def grad(x):
|
| 184 |
+
u = (np.sum(x**2) - r2) / r2
|
| 185 |
+
return (1.0 - np.tanh(u)**2) * (2.0 * x / r2)
|
| 186 |
+
|
| 187 |
+
return Landscape(
|
| 188 |
+
name="plateau", dim=dim, params={"radius": radius},
|
| 189 |
+
f=f, grad=grad, f_min=-1.0,
|
| 190 |
+
description=f"Plateau landscape in R^{dim}, radius {radius}, vanishing gradient at center.",
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def make_cliff(dim: int, **_) -> Landscape:
|
| 195 |
+
"""Smooth cliff: quadratic + tanh step. Tough for fixed-step optimizers."""
|
| 196 |
+
def f(x):
|
| 197 |
+
s = np.sum(x)
|
| 198 |
+
return float(0.5 * np.sum(x**2) + 5.0 * np.tanh(s))
|
| 199 |
+
|
| 200 |
+
def grad(x):
|
| 201 |
+
s = np.sum(x)
|
| 202 |
+
t = 1.0 - np.tanh(s)**2
|
| 203 |
+
return x + 5.0 * t * np.ones_like(x)
|
| 204 |
+
|
| 205 |
+
return Landscape(
|
| 206 |
+
name="cliff", dim=dim, params={},
|
| 207 |
+
f=f, grad=grad, f_min=-5.0, # approximate lower bound
|
| 208 |
+
description=f"Quadratic with tanh cliff in R^{dim}.",
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
BUILDERS: dict[str, Callable[..., Landscape]] = {
|
| 213 |
+
"quadratic": make_quadratic,
|
| 214 |
+
"stiff_quadratic": make_stiff_quadratic,
|
| 215 |
+
"styblinski_tang": make_styblinski_tang,
|
| 216 |
+
"huber": make_huber,
|
| 217 |
+
"rosenbrock": make_rosenbrock,
|
| 218 |
+
"gaussian_mix": make_gaussian_mix,
|
| 219 |
+
"himmelblau": make_himmelblau,
|
| 220 |
+
"plateau": make_plateau,
|
| 221 |
+
"cliff": make_cliff,
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def build_landscape(template: str, dim: int, params: dict | None = None,
|
| 226 |
+
rng: np.random.Generator | None = None) -> Landscape:
|
| 227 |
+
"""Instantiate a landscape by name."""
|
| 228 |
+
if template not in BUILDERS:
|
| 229 |
+
raise ValueError(f"Unknown template {template!r}; known: {list(BUILDERS)}")
|
| 230 |
+
return BUILDERS[template](dim=dim, rng=rng, **(params or {}))
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def structural_hints(ls: Landscape, n_samples: int = 200,
|
| 234 |
+
rng: np.random.Generator | None = None) -> dict:
|
| 235 |
+
"""Env-computed hints: Lipschitz estimate, gradient spread, modality hint.
|
| 236 |
+
|
| 237 |
+
Sampled at reset, exposed to OptCoder as free info.
|
| 238 |
+
"""
|
| 239 |
+
rng = rng if rng is not None else np.random.default_rng(0)
|
| 240 |
+
xs = rng.normal(0.0, 1.0, size=(n_samples, ls.dim))
|
| 241 |
+
fs = np.array([ls.f(x) for x in xs])
|
| 242 |
+
gs = np.array([ls.grad(x) for x in xs])
|
| 243 |
+
g_norms = np.linalg.norm(gs, axis=1)
|
| 244 |
+
return {
|
| 245 |
+
"lipschitz_estimate": float(np.percentile(g_norms, 95)),
|
| 246 |
+
"grad_norm_median": float(np.median(g_norms)),
|
| 247 |
+
"f_range": [float(fs.min()), float(fs.max())],
|
| 248 |
+
"f_median": float(np.median(fs)),
|
| 249 |
+
# crude modality: count local f peaks on random 1D slices
|
| 250 |
+
"modality_hint": _modality_hint(ls, rng),
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _modality_hint(ls: Landscape, rng: np.random.Generator) -> str:
|
| 255 |
+
"""Very crude multimodality probe on 5 random 1D slices."""
|
| 256 |
+
hits = 0
|
| 257 |
+
for _ in range(5):
|
| 258 |
+
center = rng.normal(0.0, 0.5, size=ls.dim)
|
| 259 |
+
direction = rng.normal(0.0, 1.0, size=ls.dim)
|
| 260 |
+
direction /= np.linalg.norm(direction) + 1e-12
|
| 261 |
+
ts = np.linspace(-3.0, 3.0, 30)
|
| 262 |
+
vals = np.array([ls.f(center + t * direction) for t in ts])
|
| 263 |
+
# count sign changes in finite diff
|
| 264 |
+
d = np.diff(vals)
|
| 265 |
+
s = np.sign(d)
|
| 266 |
+
sign_changes = int(np.sum(s[1:] != s[:-1]))
|
| 267 |
+
if sign_changes >= 3:
|
| 268 |
+
hits += 1
|
| 269 |
+
if hits >= 3:
|
| 270 |
+
return "multimodal"
|
| 271 |
+
if hits >= 1:
|
| 272 |
+
return "possibly_multimodal"
|
| 273 |
+
return "unimodal"
|
models.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data models for the LandscapeForge environment.
|
| 2 |
+
|
| 3 |
+
OptCoder actions are modelled as a single unified Action with a `kind`
|
| 4 |
+
discriminator. Fields are optional per-kind and validated by a model
|
| 5 |
+
validator so the HTTP envelope stays flat and easy to serialize.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Any, Literal, Optional
|
| 9 |
+
|
| 10 |
+
from openenv.core.env_server.types import Action, Observation
|
| 11 |
+
from pydantic import Field, model_validator
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
ActionKind = Literal["run_baseline", "draft", "inspect", "commit"]
|
| 15 |
+
BaselineName = Literal["sgd", "adam", "momentum", "lbfgs"]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# Per-action budget costs (§7.1 of LANDSCAPEFORGE_DESIGN.md).
|
| 19 |
+
ACTION_COSTS: dict[str, int] = {
|
| 20 |
+
"run_baseline": 2,
|
| 21 |
+
"draft": 2,
|
| 22 |
+
"inspect": 1,
|
| 23 |
+
"commit": 0,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class LandscapeforgeAction(Action):
|
| 28 |
+
"""OptCoder REPL action.
|
| 29 |
+
|
| 30 |
+
A single class covers all four action kinds; `kind` discriminates and
|
| 31 |
+
a model validator ensures each kind has its required fields.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
kind: ActionKind = Field(..., description="Which REPL action")
|
| 35 |
+
|
| 36 |
+
# run_baseline fields
|
| 37 |
+
baseline_name: Optional[BaselineName] = Field(
|
| 38 |
+
default=None, description="Reference optimizer to run"
|
| 39 |
+
)
|
| 40 |
+
# Note: steps count is env-controlled (BASELINE_STEPS in the env) — the
|
| 41 |
+
# agent does not choose it. Kept off the schema so the LLM never emits it.
|
| 42 |
+
|
| 43 |
+
# draft fields
|
| 44 |
+
code: Optional[str] = Field(
|
| 45 |
+
default=None, description="Full Optimizer class source (for kind='draft')"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# inspect fields
|
| 49 |
+
draft_idx: Optional[int] = Field(
|
| 50 |
+
default=None, ge=0, description="Which prior draft to inspect"
|
| 51 |
+
)
|
| 52 |
+
step_range_start: int = Field(default=0, ge=0)
|
| 53 |
+
step_range_end: int = Field(default=20, ge=1, le=50)
|
| 54 |
+
|
| 55 |
+
@model_validator(mode="after")
|
| 56 |
+
def _check_kind_fields(self) -> "LandscapeforgeAction":
|
| 57 |
+
k = self.kind
|
| 58 |
+
if k == "run_baseline" and self.baseline_name is None:
|
| 59 |
+
raise ValueError("run_baseline requires baseline_name")
|
| 60 |
+
if k == "draft" and not self.code:
|
| 61 |
+
raise ValueError("draft requires code")
|
| 62 |
+
if k == "inspect" and self.draft_idx is None:
|
| 63 |
+
raise ValueError("inspect requires draft_idx")
|
| 64 |
+
return self
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class LandscapeforgeObservation(Observation):
|
| 68 |
+
"""OptCoder's view of env state after an action.
|
| 69 |
+
|
| 70 |
+
Fields are self-describing strings/structured data that fit into an
|
| 71 |
+
LLM prompt. Heavy trajectory data is JSON-serializable lists.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
# Stable across the episode
|
| 75 |
+
landscape_description: str = Field(default="")
|
| 76 |
+
dim: int = Field(default=0)
|
| 77 |
+
structural_hints: dict[str, Any] = Field(default_factory=dict)
|
| 78 |
+
|
| 79 |
+
# REPL state (grows over the episode)
|
| 80 |
+
baseline_history: list[dict[str, Any]] = Field(default_factory=list)
|
| 81 |
+
draft_history: list[dict[str, Any]] = Field(default_factory=list)
|
| 82 |
+
inspect_requests: list[dict[str, Any]] = Field(default_factory=list)
|
| 83 |
+
|
| 84 |
+
current_draft: Optional[str] = Field(default=None)
|
| 85 |
+
budget_remaining: int = Field(default=0)
|
| 86 |
+
|
| 87 |
+
# Result of the immediate step
|
| 88 |
+
last_action_kind: Optional[str] = Field(default=None)
|
| 89 |
+
last_action_result: dict[str, Any] = Field(default_factory=dict)
|
| 90 |
+
|
| 91 |
+
# Terminal info (only populated after commit / budget exhausted)
|
| 92 |
+
committed: bool = Field(default=False)
|
| 93 |
+
final_regret: Optional[float] = Field(default=None)
|
| 94 |
+
r_optcoder: Optional[float] = Field(default=None)
|
| 95 |
+
r_optcoder_breakdown: dict[str, float] = Field(default_factory=dict)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: landscapeforge
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
| 7 |
+
|
openenv_landscapeforge.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: openenv-landscapeforge
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Landscapeforge environment for OpenEnv
|
| 5 |
+
Requires-Python: >=3.10
|
| 6 |
+
Requires-Dist: openenv-core[core]>=0.2.2
|
| 7 |
+
Requires-Dist: numpy<3,>=1.26
|
| 8 |
+
Requires-Dist: scipy<2,>=1.11
|
| 9 |
+
Requires-Dist: requests<3,>=2.31
|
| 10 |
+
Requires-Dist: gradio<6,>=4.44
|
| 11 |
+
Requires-Dist: matplotlib<4,>=3.8
|
| 12 |
+
Requires-Dist: plotly<7,>=5.22
|
| 13 |
+
Provides-Extra: dev
|
| 14 |
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
| 15 |
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
openenv_landscapeforge.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
__init__.py
|
| 3 |
+
arena.py
|
| 4 |
+
client.py
|
| 5 |
+
landscapes.py
|
| 6 |
+
models.py
|
| 7 |
+
prompts.py
|
| 8 |
+
pyproject.toml
|
| 9 |
+
reference_optimizers.py
|
| 10 |
+
rewards.py
|
| 11 |
+
run_llm_episode.py
|
| 12 |
+
sandbox.py
|
| 13 |
+
./__init__.py
|
| 14 |
+
./arena.py
|
| 15 |
+
./client.py
|
| 16 |
+
./landscapes.py
|
| 17 |
+
./models.py
|
| 18 |
+
./prompts.py
|
| 19 |
+
./reference_optimizers.py
|
| 20 |
+
./rewards.py
|
| 21 |
+
./run_llm_episode.py
|
| 22 |
+
./sandbox.py
|
| 23 |
+
openenv_landscapeforge.egg-info/PKG-INFO
|
| 24 |
+
openenv_landscapeforge.egg-info/SOURCES.txt
|
| 25 |
+
openenv_landscapeforge.egg-info/dependency_links.txt
|
| 26 |
+
openenv_landscapeforge.egg-info/entry_points.txt
|
| 27 |
+
openenv_landscapeforge.egg-info/requires.txt
|
| 28 |
+
openenv_landscapeforge.egg-info/top_level.txt
|
| 29 |
+
server/__init__.py
|
| 30 |
+
server/app.py
|
| 31 |
+
server/landscapeforge_environment.py
|
| 32 |
+
tests/test_episode.py
|
openenv_landscapeforge.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
openenv_landscapeforge.egg-info/entry_points.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[console_scripts]
|
| 2 |
+
server = landscapeforge.server.app:main
|
openenv_landscapeforge.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core[core]>=0.2.2
|
| 2 |
+
numpy<3,>=1.26
|
| 3 |
+
scipy<2,>=1.11
|
| 4 |
+
requests<3,>=2.31
|
| 5 |
+
gradio<6,>=4.44
|
| 6 |
+
matplotlib<4,>=3.8
|
| 7 |
+
plotly<7,>=5.22
|
| 8 |
+
|
| 9 |
+
[dev]
|
| 10 |
+
pytest>=8.0.0
|
| 11 |
+
pytest-cov>=4.0.0
|
openenv_landscapeforge.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
landscapeforge
|
prompts.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Observation → prompt rendering + LLM response → action parsing.
|
| 2 |
+
|
| 3 |
+
Keeps prompt format aligned with Appendix A of LANDSCAPEFORGE_DESIGN.md while
|
| 4 |
+
trimming obs fields that bloat tokens (e.g. full trajectories get summarised).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import re
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from .models import LandscapeforgeAction, LandscapeforgeObservation
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
SYSTEM = """You are OptCoder. You will design an optimization algorithm for a
|
| 17 |
+
hidden landscape f: R^n → R by iteratively: running reference optimizers to
|
| 18 |
+
observe their behaviour, writing candidate `Optimizer` classes and seeing how
|
| 19 |
+
they perform, inspecting past drafts to diagnose failures, and committing when
|
| 20 |
+
you are satisfied.
|
| 21 |
+
|
| 22 |
+
How the episode ends:
|
| 23 |
+
- When you call `commit`, the env runs the full arena evaluation
|
| 24 |
+
(10 seeds × 200 steps) on your MOST RECENT draft and that becomes your
|
| 25 |
+
reward. This is the normal, preferred way to finish.
|
| 26 |
+
- If you never call `commit`, when your budget runs out the env will
|
| 27 |
+
automatically do the same thing — evaluate your most recent draft.
|
| 28 |
+
Your last draft is always what gets evaluated, whether you commit
|
| 29 |
+
explicitly or the budget runs out.
|
| 30 |
+
- So: make sure your last draft is the one you actually want evaluated.
|
| 31 |
+
If you improve a draft then change your mind, re-submit the good one
|
| 32 |
+
before ending the episode.
|
| 33 |
+
|
| 34 |
+
A typical good episode is ~4 turns:
|
| 35 |
+
draft → (maybe) inspect → (maybe) refine → commit.
|
| 36 |
+
|
| 37 |
+
Reply with a single JSON object — nothing else, no prose, no markdown.
|
| 38 |
+
|
| 39 |
+
JSON formatting rules (important, models frequently get this wrong):
|
| 40 |
+
- All strings use standard JSON double-quotes: "like this"
|
| 41 |
+
- Do NOT use Python triple-quoted strings \"\"\"...\"\"\" — they are NOT valid JSON
|
| 42 |
+
- For multi-line code, escape newlines as \\n inside the string value:
|
| 43 |
+
{"kind": "draft", "code": "class Optimizer:\\n def __init__(self, dim): ..."}
|
| 44 |
+
""".strip()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
ACTION_SPEC = """
|
| 48 |
+
Available actions (cost charged against your budget):
|
| 49 |
+
|
| 50 |
+
run_baseline (cost 2) Run a reference optimizer on the hidden landscape.
|
| 51 |
+
JSON: {"kind": "run_baseline", "baseline_name": "sgd"|"momentum"|"adam"|"lbfgs"}
|
| 52 |
+
Returns a 30-step trajectory (x_t, f_t, grad_norm_t). Source code not revealed.
|
| 53 |
+
|
| 54 |
+
draft (cost 2) Submit a full Optimizer class; env auto-tests it.
|
| 55 |
+
JSON: {"kind": "draft", "code": "<python source>"}
|
| 56 |
+
The code MUST be a standalone class with no base class:
|
| 57 |
+
|
| 58 |
+
class Optimizer:
|
| 59 |
+
def __init__(self, dim):
|
| 60 |
+
...
|
| 61 |
+
def step(self, x, f_val, grad):
|
| 62 |
+
...
|
| 63 |
+
return x_new
|
| 64 |
+
|
| 65 |
+
Rules:
|
| 66 |
+
- Top-level line must be exactly: class Optimizer:
|
| 67 |
+
(no parent class — BaseOptimizer, nn.Module, object, etc. do NOT exist)
|
| 68 |
+
- Use only numpy as `np` and math — both pre-injected; DO NOT write import lines
|
| 69 |
+
- step(x, f_val, grad) must return a numpy array of shape (dim,)
|
| 70 |
+
- No I/O, no globals, no file operations
|
| 71 |
+
- Only the class definition is kept; demo code at module level is stripped
|
| 72 |
+
|
| 73 |
+
inspect (cost 1) Zoom into a prior draft's per-step behaviour.
|
| 74 |
+
JSON: {"kind": "inspect", "draft_idx": 0, "step_range_start": 10, "step_range_end": 20}
|
| 75 |
+
Returns per-step (x, f, grad, update_norm, step_size_eff).
|
| 76 |
+
|
| 77 |
+
commit (cost 0) Evaluate your most recent draft on the full arena.
|
| 78 |
+
JSON: {"kind": "commit"}
|
| 79 |
+
Preferred way to end the episode. Call it when you have a draft you
|
| 80 |
+
trust. If you don't call it, budget exhaustion triggers the same
|
| 81 |
+
evaluation on whatever your latest draft is — so your most recent
|
| 82 |
+
draft should always be your best one. Committing explicitly just
|
| 83 |
+
ends the episode sooner.
|
| 84 |
+
""".strip()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def render_observation(obs: LandscapeforgeObservation) -> str:
|
| 88 |
+
"""Turn an Observation into a compact prompt-friendly state summary."""
|
| 89 |
+
lines: list[str] = []
|
| 90 |
+
lines.append(f"Landscape: {obs.landscape_description}")
|
| 91 |
+
lines.append(f"Dim: {obs.dim}")
|
| 92 |
+
lines.append(f"Structural hints:")
|
| 93 |
+
for k, v in (obs.structural_hints or {}).items():
|
| 94 |
+
lines.append(f" {k}: {_fmt(v)}")
|
| 95 |
+
lines.append(f"Budget remaining: {obs.budget_remaining}")
|
| 96 |
+
|
| 97 |
+
if obs.baseline_history:
|
| 98 |
+
lines.append("\nBaseline runs (diagnostic trajectories):")
|
| 99 |
+
for i, b in enumerate(obs.baseline_history):
|
| 100 |
+
summary = _summarise_trajectory(b.get("trajectory", []))
|
| 101 |
+
lines.append(f" [{i}] {b['name']}: {summary}")
|
| 102 |
+
|
| 103 |
+
if obs.draft_history:
|
| 104 |
+
lines.append("\nDraft history:")
|
| 105 |
+
for i, d in enumerate(obs.draft_history):
|
| 106 |
+
if d.get("compile_error"):
|
| 107 |
+
lines.append(f" [{i}] COMPILE ERROR: {d['compile_error']}")
|
| 108 |
+
else:
|
| 109 |
+
s = d["summary"] or {}
|
| 110 |
+
status = "CONVERGED" if s.get("converged") else (
|
| 111 |
+
"DIVERGED" if s.get("diverged") else "partial"
|
| 112 |
+
)
|
| 113 |
+
lines.append(
|
| 114 |
+
f" [{i}] {status} | initial_f={_fmt(s.get('initial_f'))} "
|
| 115 |
+
f"final_f={_fmt(s.get('final_f'))} "
|
| 116 |
+
f"step_of_min={s.get('step_of_min')}"
|
| 117 |
+
)
|
| 118 |
+
code = d.get("code") or ""
|
| 119 |
+
lines.append(" code:")
|
| 120 |
+
for cl in code.splitlines()[:40]: # first 40 lines only
|
| 121 |
+
lines.append(f" {cl}")
|
| 122 |
+
|
| 123 |
+
if obs.inspect_requests:
|
| 124 |
+
lines.append("\nInspect results:")
|
| 125 |
+
for r in obs.inspect_requests:
|
| 126 |
+
detail = r.get("detail") or []
|
| 127 |
+
lines.append(
|
| 128 |
+
f" draft={r.get('draft_idx')} range={r.get('step_range')} "
|
| 129 |
+
f"({len(detail)} steps)"
|
| 130 |
+
)
|
| 131 |
+
for d in detail[:8]: # first 8 of the slice
|
| 132 |
+
lines.append(
|
| 133 |
+
f" t={d.get('t'):>3} f={_fmt(d.get('f'))} "
|
| 134 |
+
f"|g|={_fmt(d.get('grad_norm'))} "
|
| 135 |
+
f"|Δx|={_fmt(d.get('update_norm'))} "
|
| 136 |
+
f"η_eff={_fmt(d.get('step_size_eff'))}"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
if obs.current_draft:
|
| 140 |
+
lines.append(f"\nCurrent draft ({len(obs.current_draft)} chars) — will be evaluated on commit.")
|
| 141 |
+
|
| 142 |
+
if obs.last_action_kind:
|
| 143 |
+
lines.append(f"\nLast action: {obs.last_action_kind}")
|
| 144 |
+
feedback = (obs.last_action_result or {}).get("feedback")
|
| 145 |
+
if feedback:
|
| 146 |
+
parts = ", ".join(f"{k}={_fmt(v)}" for k, v in feedback.items())
|
| 147 |
+
lines.append(f"Step feedback: {parts} "
|
| 148 |
+
"(signals for your reasoning; not added to final reward)")
|
| 149 |
+
|
| 150 |
+
return "\n".join(lines)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def build_prompt(obs: LandscapeforgeObservation) -> list[dict]:
|
| 154 |
+
"""Return OpenAI-style messages list for the chat completions endpoint."""
|
| 155 |
+
state_text = render_observation(obs)
|
| 156 |
+
return [
|
| 157 |
+
{"role": "system", "content": SYSTEM},
|
| 158 |
+
{"role": "user", "content": f"{ACTION_SPEC}\n\nCurrent state:\n{state_text}\n\n"
|
| 159 |
+
"Reply with a single JSON object for your next action."},
|
| 160 |
+
]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ---------- response → action ----------
|
| 164 |
+
|
| 165 |
+
_JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def parse_action(response_text: str) -> LandscapeforgeAction:
|
| 169 |
+
"""Extract the first JSON object from the LLM's reply and build an Action.
|
| 170 |
+
|
| 171 |
+
Accepts code-fenced JSON, raw JSON, and JSON embedded in prose. Tolerates
|
| 172 |
+
the common LLM failure mode of emitting unescaped newlines / tabs inside
|
| 173 |
+
string values (especially for the `code` field of a `draft` action).
|
| 174 |
+
Raises ValueError if no parseable object is found.
|
| 175 |
+
"""
|
| 176 |
+
text = response_text.strip()
|
| 177 |
+
if text.startswith("```"):
|
| 178 |
+
text = re.sub(r"^```(?:json)?\n?", "", text)
|
| 179 |
+
text = re.sub(r"\n?```\s*$", "", text)
|
| 180 |
+
|
| 181 |
+
match = _JSON_RE.search(text)
|
| 182 |
+
if not match:
|
| 183 |
+
raise ValueError(f"No JSON object in response: {response_text[:200]!r}")
|
| 184 |
+
|
| 185 |
+
raw_json = match.group(0)
|
| 186 |
+
|
| 187 |
+
# First pass: strict.
|
| 188 |
+
try:
|
| 189 |
+
data = json.loads(raw_json)
|
| 190 |
+
except json.JSONDecodeError:
|
| 191 |
+
# Second pass: escape raw control chars inside string literals.
|
| 192 |
+
fixed = _escape_string_controls(raw_json)
|
| 193 |
+
try:
|
| 194 |
+
data = json.loads(fixed)
|
| 195 |
+
except json.JSONDecodeError as e:
|
| 196 |
+
raise ValueError(f"Invalid JSON even after control-char fix: {e}; "
|
| 197 |
+
f"raw: {raw_json[:200]!r}") from e
|
| 198 |
+
|
| 199 |
+
if "kind" not in data:
|
| 200 |
+
raise ValueError(f"Missing `kind`: {data}")
|
| 201 |
+
|
| 202 |
+
return LandscapeforgeAction(**data)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _escape_string_controls(s: str) -> str:
|
| 206 |
+
"""Escape raw newlines, carriage returns, and tabs inside JSON string literals.
|
| 207 |
+
|
| 208 |
+
Walks character-by-character tracking whether we're inside a double-quoted
|
| 209 |
+
string, and replaces raw control chars with their escaped forms. Handles
|
| 210 |
+
the common case: `"code": "class Optimizer:\\n def __init__..."` where the
|
| 211 |
+
LLM emitted literal newlines.
|
| 212 |
+
"""
|
| 213 |
+
out: list[str] = []
|
| 214 |
+
in_string = False
|
| 215 |
+
escape_next = False
|
| 216 |
+
for ch in s:
|
| 217 |
+
if escape_next:
|
| 218 |
+
out.append(ch)
|
| 219 |
+
escape_next = False
|
| 220 |
+
continue
|
| 221 |
+
if ch == "\\":
|
| 222 |
+
out.append(ch)
|
| 223 |
+
escape_next = True
|
| 224 |
+
continue
|
| 225 |
+
if ch == '"':
|
| 226 |
+
in_string = not in_string
|
| 227 |
+
out.append(ch)
|
| 228 |
+
continue
|
| 229 |
+
if in_string:
|
| 230 |
+
if ch == "\n":
|
| 231 |
+
out.append("\\n"); continue
|
| 232 |
+
if ch == "\r":
|
| 233 |
+
out.append("\\r"); continue
|
| 234 |
+
if ch == "\t":
|
| 235 |
+
out.append("\\t"); continue
|
| 236 |
+
out.append(ch)
|
| 237 |
+
return "".join(out)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ---------- helpers ----------
|
| 241 |
+
|
| 242 |
+
def _fmt(v: Any) -> str:
|
| 243 |
+
if v is None:
|
| 244 |
+
return "None"
|
| 245 |
+
if isinstance(v, float):
|
| 246 |
+
if abs(v) < 1e-4 or abs(v) >= 1e4:
|
| 247 |
+
return f"{v:.3e}"
|
| 248 |
+
return f"{v:.4f}"
|
| 249 |
+
if isinstance(v, list):
|
| 250 |
+
if len(v) <= 4:
|
| 251 |
+
return "[" + ", ".join(_fmt(x) for x in v) + "]"
|
| 252 |
+
return f"[{_fmt(v[0])}, {_fmt(v[1])}, ..., {_fmt(v[-1])}] (len={len(v)})"
|
| 253 |
+
return str(v)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _summarise_trajectory(traj: list[dict]) -> str:
|
| 257 |
+
"""Condense a 30-step baseline trajectory to head/tail snapshots."""
|
| 258 |
+
finite = [s for s in traj if s.get("f") is not None]
|
| 259 |
+
if not finite:
|
| 260 |
+
return "diverged immediately"
|
| 261 |
+
head = finite[0]
|
| 262 |
+
mid = finite[len(finite) // 2] if len(finite) > 2 else finite[-1]
|
| 263 |
+
tail = finite[-1]
|
| 264 |
+
diverged_mark = " (DIVERGED)" if len(finite) < len(traj) else ""
|
| 265 |
+
return (f"t=0: f={_fmt(head['f'])}, |g|={_fmt(head['grad_norm'])} "
|
| 266 |
+
f"→ t={mid['t']}: f={_fmt(mid['f'])} "
|
| 267 |
+
f"→ t={tail['t']}: f={_fmt(tail['f'])}{diverged_mark}")
|
pyproject.toml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
[build-system]
|
| 8 |
+
requires = ["setuptools>=45", "wheel"]
|
| 9 |
+
build-backend = "setuptools.build_meta"
|
| 10 |
+
|
| 11 |
+
[project]
|
| 12 |
+
name = "openenv-landscapeforge"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Landscapeforge environment for OpenEnv"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
+
dependencies = [
|
| 17 |
+
# Core OpenEnv runtime (provides FastAPI server + HTTP client types)
|
| 18 |
+
# install from github
|
| 19 |
+
# "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
|
| 20 |
+
"openenv-core[core]>=0.2.2",
|
| 21 |
+
"numpy>=1.26,<3",
|
| 22 |
+
"scipy>=1.11,<2",
|
| 23 |
+
"requests>=2.31,<3",
|
| 24 |
+
"gradio>=4.44,<6",
|
| 25 |
+
"matplotlib>=3.8,<4",
|
| 26 |
+
"plotly>=5.22,<7",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
[project.optional-dependencies]
|
| 30 |
+
dev = [
|
| 31 |
+
"pytest>=8.0.0",
|
| 32 |
+
"pytest-cov>=4.0.0",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
[project.scripts]
|
| 36 |
+
# Server entry point - enables running via: uv run --project . server
|
| 37 |
+
# or: python -m landscapeforge.server.app
|
| 38 |
+
server = "landscapeforge.server.app:main"
|
| 39 |
+
|
| 40 |
+
[tool.setuptools]
|
| 41 |
+
include-package-data = true
|
| 42 |
+
packages = ["landscapeforge", "landscapeforge.server"]
|
| 43 |
+
package-dir = { "landscapeforge" = ".", "landscapeforge.server" = "server" }
|
reference_optimizers.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reference optimizers run by `run_baseline` action.
|
| 2 |
+
|
| 3 |
+
These are invoked by the env — not by OptCoder's submitted code. They
|
| 4 |
+
produce diagnostic trajectories (x_t, f_t, |g_t|) that the agent sees.
|
| 5 |
+
The source code is NEVER exposed to the agent.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Callable
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _step_sgd(x, g, state, lr=0.01):
|
| 14 |
+
return x - lr * g, state
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _step_momentum(x, g, state, lr=0.01, beta=0.9):
|
| 18 |
+
v = state.get("v", np.zeros_like(x))
|
| 19 |
+
v = beta * v - lr * g
|
| 20 |
+
state["v"] = v
|
| 21 |
+
return x + v, state
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _step_adam(x, g, state, lr=0.001, b1=0.9, b2=0.999, eps=1e-8):
|
| 25 |
+
m = state.get("m", np.zeros_like(x))
|
| 26 |
+
v = state.get("v", np.zeros_like(x))
|
| 27 |
+
t = state.get("t", 0) + 1
|
| 28 |
+
m = b1 * m + (1 - b1) * g
|
| 29 |
+
v = b2 * v + (1 - b2) * g**2
|
| 30 |
+
m_hat = m / (1 - b1**t)
|
| 31 |
+
v_hat = v / (1 - b2**t)
|
| 32 |
+
state["m"], state["v"], state["t"] = m, v, t
|
| 33 |
+
return x - lr * m_hat / (np.sqrt(v_hat) + eps), state
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _run_adam_with_lr(f, grad, x0: np.ndarray, lr: float, steps: int) -> tuple[np.ndarray, float]:
|
| 37 |
+
"""Run Adam for `steps` steps from x0 with the given lr. Returns (x_final, f_final).
|
| 38 |
+
|
| 39 |
+
Used by the LR-tuning sweep for the Adam baseline. Returns (x0, inf) on divergence.
|
| 40 |
+
"""
|
| 41 |
+
x = x0.copy().astype(float)
|
| 42 |
+
state: dict = {}
|
| 43 |
+
for _ in range(steps):
|
| 44 |
+
g = np.asarray(grad(x), dtype=float)
|
| 45 |
+
x, state = _step_adam(x, g, state, lr=lr)
|
| 46 |
+
if not np.all(np.isfinite(x)):
|
| 47 |
+
return x0, float("inf")
|
| 48 |
+
return x, float(f(x))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def tune_adam_lr(f, grad, x0: np.ndarray,
|
| 52 |
+
lrs: tuple[float, ...] = (1e-4, 1e-3, 3e-3, 1e-2, 3e-2, 1e-1, 3e-1),
|
| 53 |
+
sweep_steps: int = 30) -> float:
|
| 54 |
+
"""Grid-search Adam's LR on a short run from x0. Returns the best LR.
|
| 55 |
+
|
| 56 |
+
Fair baseline for the env: the optimizer Qwen is compared against is
|
| 57 |
+
Adam-at-best-LR-for-this-landscape, not Adam-at-PyTorch-default.
|
| 58 |
+
"""
|
| 59 |
+
best_lr = lrs[0]
|
| 60 |
+
best_f = float("inf")
|
| 61 |
+
for lr in lrs:
|
| 62 |
+
_, f_final = _run_adam_with_lr(f, grad, x0, lr=lr, steps=sweep_steps)
|
| 63 |
+
if f_final < best_f:
|
| 64 |
+
best_f = f_final
|
| 65 |
+
best_lr = lr
|
| 66 |
+
return best_lr
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _step_lbfgs(x, g, state, lr=0.01, m_size=5):
|
| 70 |
+
"""Crude L-BFGS with finite-step history. Good enough as a reference."""
|
| 71 |
+
xs = state.setdefault("xs", []) # positions
|
| 72 |
+
gs = state.setdefault("gs", []) # gradients
|
| 73 |
+
|
| 74 |
+
if len(xs) < 2:
|
| 75 |
+
# First step: plain gradient descent to seed history
|
| 76 |
+
x_new = x - lr * g
|
| 77 |
+
else:
|
| 78 |
+
# Two-loop recursion over last m_size pairs
|
| 79 |
+
s_list, y_list, rho_list = [], [], []
|
| 80 |
+
for i in range(1, min(m_size, len(xs)) + 1):
|
| 81 |
+
s = xs[-i] - xs[-i - 1] if len(xs) > i else None
|
| 82 |
+
if s is None:
|
| 83 |
+
continue
|
| 84 |
+
y = gs[-i] - gs[-i - 1]
|
| 85 |
+
denom = float(y @ s)
|
| 86 |
+
if abs(denom) < 1e-12:
|
| 87 |
+
continue
|
| 88 |
+
s_list.append(s); y_list.append(y); rho_list.append(1.0 / denom)
|
| 89 |
+
|
| 90 |
+
q = g.copy()
|
| 91 |
+
alpha = []
|
| 92 |
+
for s, y, rho in zip(s_list, y_list, rho_list):
|
| 93 |
+
a = rho * float(s @ q)
|
| 94 |
+
alpha.append(a)
|
| 95 |
+
q = q - a * y
|
| 96 |
+
|
| 97 |
+
# H0 scaling
|
| 98 |
+
if y_list:
|
| 99 |
+
y0 = y_list[0]; s0 = s_list[0]
|
| 100 |
+
gamma = float(s0 @ y0) / (float(y0 @ y0) + 1e-12)
|
| 101 |
+
else:
|
| 102 |
+
gamma = 1.0
|
| 103 |
+
r = gamma * q
|
| 104 |
+
|
| 105 |
+
for (s, y, rho), a in zip(reversed(list(zip(s_list, y_list, rho_list))), reversed(alpha)):
|
| 106 |
+
b = rho * float(y @ r)
|
| 107 |
+
r = r + (a - b) * s
|
| 108 |
+
|
| 109 |
+
x_new = x - lr * r
|
| 110 |
+
|
| 111 |
+
xs.append(x.copy())
|
| 112 |
+
gs.append(g.copy())
|
| 113 |
+
return x_new, state
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
BASELINES: dict[str, Callable] = {
|
| 117 |
+
"sgd": _step_sgd,
|
| 118 |
+
"momentum": _step_momentum,
|
| 119 |
+
"adam": _step_adam,
|
| 120 |
+
"lbfgs": _step_lbfgs,
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def run_baseline(name: str, f, grad, x0: np.ndarray, steps: int = 30) -> dict:
|
| 125 |
+
"""Run a reference optimizer from x0 for `steps` steps.
|
| 126 |
+
|
| 127 |
+
Returns a trajectory dict with per-step (x, f, |g|).
|
| 128 |
+
"""
|
| 129 |
+
if name not in BASELINES:
|
| 130 |
+
raise ValueError(f"Unknown baseline {name!r}")
|
| 131 |
+
step_fn = BASELINES[name]
|
| 132 |
+
x = x0.copy().astype(float)
|
| 133 |
+
state: dict = {}
|
| 134 |
+
traj = []
|
| 135 |
+
for t in range(steps):
|
| 136 |
+
fv = float(f(x))
|
| 137 |
+
g = np.asarray(grad(x), dtype=float)
|
| 138 |
+
gn = float(np.linalg.norm(g))
|
| 139 |
+
traj.append({"t": t, "x": x.tolist(), "f": fv, "grad_norm": gn})
|
| 140 |
+
x, state = step_fn(x, g, state)
|
| 141 |
+
if not np.all(np.isfinite(x)):
|
| 142 |
+
# Pad with the last finite state; record divergence
|
| 143 |
+
traj.append({"t": t + 1, "x": None, "f": None, "grad_norm": None,
|
| 144 |
+
"diverged": True})
|
| 145 |
+
break
|
| 146 |
+
# Final state
|
| 147 |
+
if np.all(np.isfinite(x)):
|
| 148 |
+
traj.append({"t": len(traj), "x": x.tolist(), "f": float(f(x)),
|
| 149 |
+
"grad_norm": float(np.linalg.norm(np.asarray(grad(x))))})
|
| 150 |
+
return {"name": name, "trajectory": traj, "final_x": x.tolist()}
|
rewards.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reward computation for OptCoder and LandscapeForge.
|
| 2 |
+
|
| 3 |
+
Matches §9 of LANDSCAPEFORGE_DESIGN.md (v0.2).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from .arena import ArenaResult
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# Default weights (§9.1)
|
| 16 |
+
W_REGRET = 1.0
|
| 17 |
+
W_CONV = 0.3
|
| 18 |
+
W_ROBUST = 0.3
|
| 19 |
+
W_NOVELTY = 0.1
|
| 20 |
+
W_BUDGET = 0.05
|
| 21 |
+
W_EVAL_FAIL = 0.5
|
| 22 |
+
|
| 23 |
+
NOVELTY_GATE = 0.5 # novelty only applied when r_regret > this
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class OptCoderReward:
|
| 28 |
+
r_total: float
|
| 29 |
+
breakdown: dict[str, float]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def compute_optcoder_reward(
|
| 33 |
+
arena: ArenaResult,
|
| 34 |
+
adam_arena: ArenaResult,
|
| 35 |
+
actions_used_cost: int, # sum of per-action costs, not count
|
| 36 |
+
budget_total: int,
|
| 37 |
+
novelty_score: float, # AST edit distance / len, clamped to [0, 1]
|
| 38 |
+
convergence_step: int | None,
|
| 39 |
+
arena_steps: int,
|
| 40 |
+
) -> OptCoderReward:
|
| 41 |
+
"""Compute OptCoder's terminal reward (no `f_min` dependency).
|
| 42 |
+
|
| 43 |
+
r_regret is driven by Adam-relative descent:
|
| 44 |
+
my_progress = mean(f_initial - f_final) across seeds for the draft
|
| 45 |
+
adam_progress = same for Adam
|
| 46 |
+
r_regret = clamp(my_progress / max(adam_progress, floor) - 1, -1, +1)
|
| 47 |
+
|
| 48 |
+
Interpretation:
|
| 49 |
+
r_regret = +1 → descended ≥ 2× as far as Adam
|
| 50 |
+
r_regret = 0 → matched Adam's descent
|
| 51 |
+
r_regret = -1 → descended ≤ 0 while Adam descended normally
|
| 52 |
+
"""
|
| 53 |
+
my_progress = arena.mean_progress
|
| 54 |
+
adam_progress = adam_arena.mean_progress
|
| 55 |
+
# Denominator floor: if Adam barely descended (e.g. plateau landscape),
|
| 56 |
+
# use ~1% of initial |f| to avoid a tiny denominator exploding the ratio.
|
| 57 |
+
denom_floor = 0.01 * adam_arena.mean_initial_scale + 1e-6
|
| 58 |
+
denom = max(adam_progress, denom_floor)
|
| 59 |
+
|
| 60 |
+
r_regret_raw = my_progress / denom - 1.0
|
| 61 |
+
r_regret = float(np.clip(r_regret_raw, -1.0, 1.0))
|
| 62 |
+
|
| 63 |
+
# Convergence speed: 1 if hit 1% of initial f before N steps
|
| 64 |
+
if convergence_step is None or convergence_step >= arena_steps:
|
| 65 |
+
r_conv = 0.0
|
| 66 |
+
else:
|
| 67 |
+
r_conv = float(np.clip(1.0 - convergence_step / arena_steps, 0.0, 1.0))
|
| 68 |
+
|
| 69 |
+
r_robust = arena.robustness
|
| 70 |
+
|
| 71 |
+
# Novelty gated on regret performance
|
| 72 |
+
r_novelty = float(novelty_score) if r_regret > NOVELTY_GATE else 0.0
|
| 73 |
+
|
| 74 |
+
r_budget = float(np.clip(actions_used_cost / max(budget_total, 1), 0.0, 1.0))
|
| 75 |
+
r_eval_fail = arena.crash_fraction
|
| 76 |
+
|
| 77 |
+
total = (
|
| 78 |
+
W_REGRET * r_regret
|
| 79 |
+
+ W_CONV * r_conv
|
| 80 |
+
+ W_ROBUST * r_robust
|
| 81 |
+
+ W_NOVELTY * r_novelty
|
| 82 |
+
- W_BUDGET * r_budget
|
| 83 |
+
- W_EVAL_FAIL * r_eval_fail
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
return OptCoderReward(
|
| 87 |
+
r_total=float(total),
|
| 88 |
+
breakdown={
|
| 89 |
+
"r_regret": r_regret,
|
| 90 |
+
"r_convergence": r_conv,
|
| 91 |
+
"r_robustness": r_robust,
|
| 92 |
+
"r_novelty": r_novelty,
|
| 93 |
+
"r_budget": r_budget,
|
| 94 |
+
"r_eval_failures": r_eval_fail,
|
| 95 |
+
"my_progress": float(my_progress),
|
| 96 |
+
"adam_progress": float(adam_progress),
|
| 97 |
+
"speedup_vs_adam": float(my_progress / denom),
|
| 98 |
+
},
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def ast_novelty_score(committed_source: str, reference_sources: list[str]) -> float:
|
| 103 |
+
"""Coarse AST-diff score vs a set of reference optimizers.
|
| 104 |
+
|
| 105 |
+
Returns min over references of (edit distance / len of committed), clamped
|
| 106 |
+
to [0, 1]. Near-zero means heavy copy. For v1 we use a simple char-level
|
| 107 |
+
Levenshtein-ish ratio; AST diffing is deferred.
|
| 108 |
+
"""
|
| 109 |
+
if not committed_source:
|
| 110 |
+
return 0.0
|
| 111 |
+
best = 1.0
|
| 112 |
+
for ref in reference_sources:
|
| 113 |
+
ratio = _diff_ratio(committed_source, ref)
|
| 114 |
+
if ratio < best:
|
| 115 |
+
best = ratio
|
| 116 |
+
return float(np.clip(best, 0.0, 1.0))
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _diff_ratio(a: str, b: str) -> float:
|
| 120 |
+
"""difflib-based ratio: 1 - similarity. Cheap, roughly AST-order-insensitive."""
|
| 121 |
+
import difflib
|
| 122 |
+
sim = difflib.SequenceMatcher(None, a, b).ratio()
|
| 123 |
+
return 1.0 - sim
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---------- Stepwise FEEDBACK (not reward) ----------
|
| 127 |
+
# These signals are exposed to the LLM through the observation so it can
|
| 128 |
+
# course-correct mid-episode. They are NOT summed into the training reward —
|
| 129 |
+
# terminal arena reward is the only GRPO signal, to preserve robustness.
|
| 130 |
+
|
| 131 |
+
COMPILE_PENALTY_SIGNAL = -0.1
|
| 132 |
+
PHI_SCALE = 10.0 # normalizer for best_draft_f to keep potential in ~[-1, 0]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _draft_potential(draft_history: list[dict]) -> float:
|
| 136 |
+
"""Potential: higher = better. No drafts yet → -1.0 (worst) so that the
|
| 137 |
+
first valid draft always emits a positive phi_delta proportional to its
|
| 138 |
+
quality. Clamped to [-1, 0].
|
| 139 |
+
"""
|
| 140 |
+
finals = [
|
| 141 |
+
d["summary"]["final_f"]
|
| 142 |
+
for d in draft_history
|
| 143 |
+
if d.get("summary") and d["summary"].get("final_f") is not None
|
| 144 |
+
]
|
| 145 |
+
if not finals:
|
| 146 |
+
return -1.0 # no valid draft yet → worst-case potential
|
| 147 |
+
best = min(finals)
|
| 148 |
+
normed = min(max(best, 0.0), PHI_SCALE) / PHI_SCALE
|
| 149 |
+
return -normed # lower best_f → higher potential
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def compute_step_reward(prev_draft_history: list[dict],
|
| 153 |
+
new_draft_history: list[dict],
|
| 154 |
+
action_kind: str,
|
| 155 |
+
action_result: dict) -> dict:
|
| 156 |
+
"""Stepwise FEEDBACK signals for one REPL turn.
|
| 157 |
+
|
| 158 |
+
Despite the name (kept for API compatibility), this does NOT produce
|
| 159 |
+
training reward. The returned dict is structured for surfacing to the
|
| 160 |
+
LLM's next prompt as part of `last_action_result.feedback`:
|
| 161 |
+
|
| 162 |
+
- `phi_delta`: improvement in best-draft-so-far. Positive = the latest
|
| 163 |
+
action moved the best known draft closer to the minimum.
|
| 164 |
+
- `compile_penalty`: -0.1 marker indicating the last draft failed to
|
| 165 |
+
compile. Helps the agent notice structural bugs immediately.
|
| 166 |
+
|
| 167 |
+
Caller should NOT add the numeric values to the training reward.
|
| 168 |
+
"""
|
| 169 |
+
breakdown: dict[str, float] = {}
|
| 170 |
+
|
| 171 |
+
phi_prev = _draft_potential(prev_draft_history)
|
| 172 |
+
phi_new = _draft_potential(new_draft_history)
|
| 173 |
+
phi_delta = phi_new - phi_prev
|
| 174 |
+
if abs(phi_delta) > 1e-9:
|
| 175 |
+
breakdown["phi_delta"] = float(phi_delta)
|
| 176 |
+
|
| 177 |
+
if action_kind == "draft" and action_result.get("compile_error"):
|
| 178 |
+
breakdown["compile_penalty"] = COMPILE_PENALTY_SIGNAL
|
| 179 |
+
|
| 180 |
+
# r_step retained for backwards compatibility but caller must ignore it
|
| 181 |
+
# for training purposes (see docstring).
|
| 182 |
+
r_step = float(sum(breakdown.values()))
|
| 183 |
+
return {"r_step": r_step, "breakdown": breakdown}
|
run_llm_episode.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Drive one LandscapeForge episode with an LLM.
|
| 2 |
+
|
| 3 |
+
Works with any OpenAI-compatible /v1/chat/completions endpoint:
|
| 4 |
+
|
| 5 |
+
# HuggingFace router (default)
|
| 6 |
+
HF_TOKEN=hf_xxx python -m landscapeforge.run_llm_episode
|
| 7 |
+
|
| 8 |
+
# Ollama (no key needed — base URL override)
|
| 9 |
+
API_BASE_URL=http://localhost:11434/v1 \\
|
| 10 |
+
MODEL_NAME=qwen2.5:3b \\
|
| 11 |
+
python -m landscapeforge.run_llm_episode
|
| 12 |
+
|
| 13 |
+
# Optional: pick a tier and seed
|
| 14 |
+
LF_TIER=T0 LF_SEED=42 python -m landscapeforge.run_llm_episode
|
| 15 |
+
|
| 16 |
+
Prints every action the model takes, the environment's feedback, and the
|
| 17 |
+
final commit result.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import datetime as _dt
|
| 23 |
+
import json
|
| 24 |
+
import os
|
| 25 |
+
import sys
|
| 26 |
+
import textwrap
|
| 27 |
+
import time
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any
|
| 30 |
+
|
| 31 |
+
import requests
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from .models import LandscapeforgeAction
|
| 35 |
+
from .prompts import build_prompt, parse_action
|
| 36 |
+
from .server.landscapeforge_environment import LandscapeforgeEnvironment
|
| 37 |
+
except ImportError: # pragma: no cover
|
| 38 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 39 |
+
from landscapeforge.models import LandscapeforgeAction
|
| 40 |
+
from landscapeforge.prompts import build_prompt, parse_action
|
| 41 |
+
from landscapeforge.server.landscapeforge_environment import LandscapeforgeEnvironment
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 45 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 46 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-7B-Instruct")
|
| 47 |
+
|
| 48 |
+
TEMPERATURE = float(os.getenv("LF_TEMPERATURE", "0.6"))
|
| 49 |
+
MAX_TOKENS = int(os.getenv("LF_MAX_TOKENS", "1200"))
|
| 50 |
+
MAX_TURNS = int(os.getenv("LF_MAX_TURNS", "12"))
|
| 51 |
+
TIER = os.getenv("LF_TIER", "T0")
|
| 52 |
+
SEED = int(os.getenv("LF_SEED", "42"))
|
| 53 |
+
LOG_DIR = Path(os.getenv("LF_LOG_DIR", "./episode_logs"))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def call_llm(messages: list[dict]) -> str:
|
| 57 |
+
"""Single chat completion call. Returns the assistant's content string."""
|
| 58 |
+
url = API_BASE_URL.rstrip("/") + "/chat/completions"
|
| 59 |
+
headers = {"Content-Type": "application/json"}
|
| 60 |
+
if API_KEY:
|
| 61 |
+
headers["Authorization"] = f"Bearer {API_KEY}"
|
| 62 |
+
|
| 63 |
+
payload = {
|
| 64 |
+
"model": MODEL_NAME,
|
| 65 |
+
"messages": messages,
|
| 66 |
+
"temperature": TEMPERATURE,
|
| 67 |
+
"max_tokens": MAX_TOKENS,
|
| 68 |
+
"stream": False,
|
| 69 |
+
}
|
| 70 |
+
r = requests.post(url, headers=headers, json=payload, timeout=180)
|
| 71 |
+
if r.status_code >= 400:
|
| 72 |
+
raise RuntimeError(f"LLM call failed [{r.status_code}]: {r.text[:400]}")
|
| 73 |
+
body = r.json()
|
| 74 |
+
return body["choices"][0]["message"]["content"]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def pretty_action(action: LandscapeforgeAction) -> str:
|
| 78 |
+
if action.kind == "run_baseline":
|
| 79 |
+
return f"run_baseline(name={action.baseline_name!r})"
|
| 80 |
+
if action.kind == "draft":
|
| 81 |
+
code = (action.code or "").strip()
|
| 82 |
+
lines = code.splitlines()
|
| 83 |
+
header = f"draft ({len(action.code or '')} chars, {len(lines)} lines):"
|
| 84 |
+
# Indent the code block so it's clearly nested under the action line.
|
| 85 |
+
body = textwrap.indent(code, " │ ")
|
| 86 |
+
return f"{header}\n{body}"
|
| 87 |
+
if action.kind == "inspect":
|
| 88 |
+
return f"inspect(draft_idx={action.draft_idx}, range=[{action.step_range_start},{action.step_range_end}])"
|
| 89 |
+
if action.kind == "commit":
|
| 90 |
+
return "commit()"
|
| 91 |
+
return str(action)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def pretty_result(result: dict) -> str:
|
| 95 |
+
"""Compact one-line summary of the env's step result."""
|
| 96 |
+
keys_of_interest = (
|
| 97 |
+
"baseline_index", "name", "n_steps", "final_f",
|
| 98 |
+
"draft_index", "compile_error",
|
| 99 |
+
"step_range", "feedback",
|
| 100 |
+
"reason", "mean_regret", "crash_fraction",
|
| 101 |
+
"novelty_score", "convergence_step",
|
| 102 |
+
)
|
| 103 |
+
parts = []
|
| 104 |
+
for k in keys_of_interest:
|
| 105 |
+
if k in result and result[k] is not None:
|
| 106 |
+
if k == "feedback":
|
| 107 |
+
parts.append(f"feedback={result[k]}")
|
| 108 |
+
elif isinstance(result[k], float):
|
| 109 |
+
parts.append(f"{k}={result[k]:.4g}")
|
| 110 |
+
elif isinstance(result[k], dict):
|
| 111 |
+
parts.append(f"{k}=<dict>")
|
| 112 |
+
else:
|
| 113 |
+
parts.append(f"{k}={result[k]}")
|
| 114 |
+
return ", ".join(parts) or "ok"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _new_log_files() -> tuple[Path, Path]:
|
| 118 |
+
"""Create a timestamped .jsonl (structured) + .md (human-readable) pair."""
|
| 119 |
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 120 |
+
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 121 |
+
model_tag = MODEL_NAME.replace("/", "_").replace(":", "_")
|
| 122 |
+
stem = f"{ts}_{model_tag}_seed{SEED}"
|
| 123 |
+
return LOG_DIR / f"{stem}.jsonl", LOG_DIR / f"{stem}.md"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _log_jsonl(path: Path, event: dict) -> None:
|
| 127 |
+
with path.open("a") as f:
|
| 128 |
+
f.write(json.dumps(event, default=str) + "\n")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _log_md(path: Path, text: str) -> None:
|
| 132 |
+
with path.open("a") as f:
|
| 133 |
+
f.write(text + "\n")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def main() -> None:
|
| 137 |
+
jsonl_path, md_path = _new_log_files()
|
| 138 |
+
|
| 139 |
+
header = (
|
| 140 |
+
"=" * 78 + "\n"
|
| 141 |
+
"LandscapeForge — LLM episode runner\n"
|
| 142 |
+
+ "=" * 78 + "\n"
|
| 143 |
+
f"Model: {MODEL_NAME}\n"
|
| 144 |
+
f"Endpoint: {API_BASE_URL}\n"
|
| 145 |
+
f"Auth: {'Bearer token present' if API_KEY else 'none (local endpoint)'}\n"
|
| 146 |
+
f"Temperature: {TEMPERATURE}\n"
|
| 147 |
+
f"Tier: {TIER}\n"
|
| 148 |
+
f"Seed: {SEED}\n"
|
| 149 |
+
f"Log dir: {LOG_DIR.resolve()}\n"
|
| 150 |
+
f" jsonl: {jsonl_path.name}\n"
|
| 151 |
+
f" markdown: {md_path.name}\n"
|
| 152 |
+
)
|
| 153 |
+
print(header)
|
| 154 |
+
|
| 155 |
+
_log_md(md_path, f"# Episode log — {MODEL_NAME} seed={SEED} tier={TIER}\n")
|
| 156 |
+
_log_md(md_path, f"```\n{header.strip()}\n```\n")
|
| 157 |
+
_log_jsonl(jsonl_path, {
|
| 158 |
+
"event": "episode_start",
|
| 159 |
+
"model": MODEL_NAME, "endpoint": API_BASE_URL,
|
| 160 |
+
"temperature": TEMPERATURE, "tier": TIER, "seed": SEED,
|
| 161 |
+
"timestamp": _dt.datetime.now().isoformat(),
|
| 162 |
+
})
|
| 163 |
+
|
| 164 |
+
env = LandscapeforgeEnvironment(tier=TIER, seed=SEED)
|
| 165 |
+
obs = env.reset()
|
| 166 |
+
reset_line = (
|
| 167 |
+
f"Landscape chosen: {obs.landscape_description}\n"
|
| 168 |
+
f"Dim: {obs.dim}\n"
|
| 169 |
+
f"Structural hints: {obs.structural_hints}\n"
|
| 170 |
+
f"Initial budget: {obs.budget_remaining}\n"
|
| 171 |
+
)
|
| 172 |
+
print(reset_line)
|
| 173 |
+
_log_md(md_path, "## Reset\n```\n" + reset_line + "```\n")
|
| 174 |
+
_log_jsonl(jsonl_path, {
|
| 175 |
+
"event": "reset",
|
| 176 |
+
"landscape_description": obs.landscape_description,
|
| 177 |
+
"dim": obs.dim,
|
| 178 |
+
"structural_hints": obs.structural_hints,
|
| 179 |
+
"budget_remaining": obs.budget_remaining,
|
| 180 |
+
})
|
| 181 |
+
|
| 182 |
+
for turn in range(1, MAX_TURNS + 1):
|
| 183 |
+
turn_header = f"─── turn {turn} ─────────────────────────────────────────────────────"
|
| 184 |
+
print(turn_header)
|
| 185 |
+
_log_md(md_path, f"\n## Turn {turn}\n")
|
| 186 |
+
|
| 187 |
+
messages = build_prompt(obs)
|
| 188 |
+
# Prompt is large, so log it to the files but not console.
|
| 189 |
+
_log_md(md_path, "### Prompt (user message)\n```\n"
|
| 190 |
+
+ messages[-1]["content"] + "\n```\n")
|
| 191 |
+
_log_jsonl(jsonl_path, {
|
| 192 |
+
"event": "prompt",
|
| 193 |
+
"turn": turn,
|
| 194 |
+
"messages": messages,
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
t0 = time.time()
|
| 198 |
+
try:
|
| 199 |
+
raw = call_llm(messages)
|
| 200 |
+
except Exception as e:
|
| 201 |
+
print(f"[LLM error] {e}")
|
| 202 |
+
_log_jsonl(jsonl_path, {"event": "llm_error", "turn": turn, "error": str(e)})
|
| 203 |
+
_log_md(md_path, f"### LLM error\n```\n{e}\n```\n")
|
| 204 |
+
break
|
| 205 |
+
dt = time.time() - t0
|
| 206 |
+
print(f"[LLM reply in {dt:.1f}s, {len(raw)} chars]")
|
| 207 |
+
|
| 208 |
+
_log_md(md_path, "### Raw LLM reply\n```\n" + raw + "\n```\n")
|
| 209 |
+
_log_jsonl(jsonl_path, {
|
| 210 |
+
"event": "llm_reply",
|
| 211 |
+
"turn": turn,
|
| 212 |
+
"latency_s": dt,
|
| 213 |
+
"raw": raw,
|
| 214 |
+
})
|
| 215 |
+
|
| 216 |
+
try:
|
| 217 |
+
action = parse_action(raw)
|
| 218 |
+
except Exception as e:
|
| 219 |
+
print(f"[parse error] {e}")
|
| 220 |
+
print("--- raw reply (first 400 chars) ---")
|
| 221 |
+
print(raw[:400])
|
| 222 |
+
print("-----------------------------------")
|
| 223 |
+
_log_jsonl(jsonl_path, {
|
| 224 |
+
"event": "parse_error", "turn": turn, "error": str(e),
|
| 225 |
+
"raw_first_400": raw[:400],
|
| 226 |
+
})
|
| 227 |
+
_log_md(md_path, f"### Parse error\n```\n{e}\n```\n")
|
| 228 |
+
break
|
| 229 |
+
|
| 230 |
+
pretty = pretty_action(action)
|
| 231 |
+
print(f"action: {pretty}")
|
| 232 |
+
_log_md(md_path, "### Action\n```\n" + pretty + "\n```\n")
|
| 233 |
+
_log_jsonl(jsonl_path, {
|
| 234 |
+
"event": "action", "turn": turn,
|
| 235 |
+
"action": action.model_dump(exclude_none=True),
|
| 236 |
+
})
|
| 237 |
+
|
| 238 |
+
obs = env.step(action)
|
| 239 |
+
result_line = pretty_result(obs.last_action_result)
|
| 240 |
+
print(f" → {result_line}")
|
| 241 |
+
print(f" budget remaining: {obs.budget_remaining}")
|
| 242 |
+
_log_md(md_path, "### Step result\n```\n"
|
| 243 |
+
+ f"→ {result_line}\nbudget_remaining={obs.budget_remaining}\n```\n")
|
| 244 |
+
_log_jsonl(jsonl_path, {
|
| 245 |
+
"event": "step_result", "turn": turn,
|
| 246 |
+
"last_action_result": obs.last_action_result,
|
| 247 |
+
"budget_remaining": obs.budget_remaining,
|
| 248 |
+
"done": obs.done,
|
| 249 |
+
"reward_so_far": obs.reward,
|
| 250 |
+
})
|
| 251 |
+
|
| 252 |
+
if obs.done:
|
| 253 |
+
final_block = (
|
| 254 |
+
"═══ EPISODE DONE ═══\n"
|
| 255 |
+
f" reason : {obs.last_action_result.get('reason')}\n"
|
| 256 |
+
f" final_regret : {obs.final_regret}\n"
|
| 257 |
+
f" terminal reward : {obs.r_optcoder}\n"
|
| 258 |
+
f" breakdown : {obs.r_optcoder_breakdown}\n"
|
| 259 |
+
)
|
| 260 |
+
print()
|
| 261 |
+
print(final_block)
|
| 262 |
+
_log_md(md_path, "\n## Episode done\n```\n" + final_block + "```\n")
|
| 263 |
+
_log_jsonl(jsonl_path, {
|
| 264 |
+
"event": "episode_done",
|
| 265 |
+
"reason": obs.last_action_result.get("reason"),
|
| 266 |
+
"final_regret": obs.final_regret,
|
| 267 |
+
"r_optcoder": obs.r_optcoder,
|
| 268 |
+
"r_optcoder_breakdown": obs.r_optcoder_breakdown,
|
| 269 |
+
"last_action_result": obs.last_action_result,
|
| 270 |
+
})
|
| 271 |
+
print(f"\n[logged] full transcript at:\n {jsonl_path}\n {md_path}")
|
| 272 |
+
return
|
| 273 |
+
|
| 274 |
+
print("\n[!] Reached MAX_TURNS without commit — agent never committed.")
|
| 275 |
+
_log_jsonl(jsonl_path, {"event": "max_turns_reached", "max_turns": MAX_TURNS})
|
| 276 |
+
_log_md(md_path, f"\n[!] Reached MAX_TURNS ({MAX_TURNS}) without commit.\n")
|
| 277 |
+
print(f"\n[logged] transcript at:\n {jsonl_path}\n {md_path}")
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
if __name__ == "__main__":
|
| 281 |
+
main()
|
sandbox.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sandbox for executing OptCoder-submitted optimizer code.
|
| 2 |
+
|
| 3 |
+
In-process exec with:
|
| 4 |
+
- AST strip of module-level demo code (keeps only imports + `class Optimizer`)
|
| 5 |
+
- Restricted globals (only `np` and `math` exposed)
|
| 6 |
+
- Signal-based 1-second timeout on instantiation and each step call
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import ast
|
| 12 |
+
import math
|
| 13 |
+
import signal
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SandboxError(Exception):
|
| 21 |
+
"""Raised for any sandbox-level failure (syntax, timeout, security)."""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class StepTimeout(SandboxError):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _signal_timeout(seconds: float):
|
| 29 |
+
"""Context manager using SIGALRM to bound execution time."""
|
| 30 |
+
from contextlib import contextmanager
|
| 31 |
+
|
| 32 |
+
@contextmanager
|
| 33 |
+
def _cm():
|
| 34 |
+
def handler(signum, frame):
|
| 35 |
+
raise StepTimeout(f"exceeded {seconds}s")
|
| 36 |
+
|
| 37 |
+
old = signal.signal(signal.SIGALRM, handler)
|
| 38 |
+
# setitimer supports sub-second timers (signal.alarm only takes ints)
|
| 39 |
+
signal.setitimer(signal.ITIMER_REAL, seconds)
|
| 40 |
+
try:
|
| 41 |
+
yield
|
| 42 |
+
finally:
|
| 43 |
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
| 44 |
+
signal.signal(signal.SIGALRM, old)
|
| 45 |
+
|
| 46 |
+
return _cm()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def strip_module_code(source: str) -> str:
|
| 50 |
+
"""Keep only the `class Optimizer` node.
|
| 51 |
+
|
| 52 |
+
Drops imports (the sandbox pre-injects np/numpy/math into globals),
|
| 53 |
+
hallucinated demo functions, `if __name__ == '__main__'` blocks, and
|
| 54 |
+
trailing execution code that frequently appears in LLM output.
|
| 55 |
+
"""
|
| 56 |
+
try:
|
| 57 |
+
tree = ast.parse(source)
|
| 58 |
+
except SyntaxError as e:
|
| 59 |
+
raise SandboxError(f"SyntaxError: {e}") from e
|
| 60 |
+
|
| 61 |
+
kept: list[ast.stmt] = []
|
| 62 |
+
found_class = False
|
| 63 |
+
for node in tree.body:
|
| 64 |
+
if isinstance(node, ast.ClassDef) and node.name == "Optimizer":
|
| 65 |
+
kept.append(node)
|
| 66 |
+
found_class = True
|
| 67 |
+
# Imports are dropped — env provides np/numpy/math via globals.
|
| 68 |
+
|
| 69 |
+
if not found_class:
|
| 70 |
+
raise SandboxError("No `class Optimizer` found in submission")
|
| 71 |
+
|
| 72 |
+
new_tree = ast.Module(body=kept, type_ignores=[])
|
| 73 |
+
ast.fix_missing_locations(new_tree)
|
| 74 |
+
return ast.unparse(new_tree)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _safe_globals() -> dict:
|
| 78 |
+
"""Globals exposed to submitted code. Minimal builtins + np/numpy/math."""
|
| 79 |
+
import builtins as _bi
|
| 80 |
+
|
| 81 |
+
safe_names = [
|
| 82 |
+
# numeric / iteration
|
| 83 |
+
"abs", "min", "max", "sum", "len", "range", "zip", "enumerate",
|
| 84 |
+
"list", "tuple", "dict", "set", "float", "int", "bool", "str",
|
| 85 |
+
"round", "divmod", "pow", "reversed", "sorted", "any", "all", "map", "filter",
|
| 86 |
+
# introspection (safe subset)
|
| 87 |
+
"isinstance", "issubclass", "hasattr", "getattr", "setattr",
|
| 88 |
+
"True", "False", "None",
|
| 89 |
+
# class definition machinery (required to define `class Optimizer`)
|
| 90 |
+
"__build_class__", "__name__", "object", "super",
|
| 91 |
+
"type", "property", "staticmethod", "classmethod",
|
| 92 |
+
# errors (so submitted code can raise/catch sanely)
|
| 93 |
+
"Exception", "ValueError", "TypeError", "IndexError", "KeyError",
|
| 94 |
+
"ZeroDivisionError", "RuntimeError", "ArithmeticError", "OverflowError",
|
| 95 |
+
]
|
| 96 |
+
safe_bi = {n: getattr(_bi, n) for n in safe_names if hasattr(_bi, n)}
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"__builtins__": safe_bi,
|
| 100 |
+
"__name__": "__submission__",
|
| 101 |
+
"np": np,
|
| 102 |
+
"numpy": np,
|
| 103 |
+
"math": math,
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@dataclass
|
| 108 |
+
class CompiledOptimizer:
|
| 109 |
+
"""Wraps an instantiated Optimizer with bounded `step` execution."""
|
| 110 |
+
instance: Any
|
| 111 |
+
step_timeout: float = 0.5
|
| 112 |
+
|
| 113 |
+
def step(self, x: np.ndarray, f_val: float, grad: np.ndarray) -> np.ndarray:
|
| 114 |
+
with _signal_timeout(self.step_timeout):
|
| 115 |
+
try:
|
| 116 |
+
out = self.instance.step(x, f_val, grad)
|
| 117 |
+
except StepTimeout:
|
| 118 |
+
raise
|
| 119 |
+
except Exception as e:
|
| 120 |
+
raise SandboxError(f"step() raised {type(e).__name__}: {e}") from e
|
| 121 |
+
try:
|
| 122 |
+
out = np.asarray(out, dtype=float)
|
| 123 |
+
except Exception as e:
|
| 124 |
+
raise SandboxError(f"step() returned non-array value ({type(e).__name__}: {e})") from e
|
| 125 |
+
if out.shape != x.shape:
|
| 126 |
+
raise SandboxError(f"step() returned shape {out.shape}, expected {x.shape}")
|
| 127 |
+
if not np.all(np.isfinite(out)):
|
| 128 |
+
raise SandboxError("step() returned non-finite values")
|
| 129 |
+
return out
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def compile_optimizer(source: str, dim: int, init_timeout: float = 1.0,
|
| 133 |
+
step_timeout: float = 0.5) -> CompiledOptimizer:
|
| 134 |
+
"""Strip, exec, and instantiate Optimizer(dim=dim). Returns a wrapper."""
|
| 135 |
+
stripped = strip_module_code(source)
|
| 136 |
+
globs = _safe_globals()
|
| 137 |
+
locs: dict = {}
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
with _signal_timeout(init_timeout):
|
| 141 |
+
exec(compile(stripped, "<submission>", "exec"), globs, locs)
|
| 142 |
+
except SandboxError:
|
| 143 |
+
raise
|
| 144 |
+
except Exception as e:
|
| 145 |
+
raise SandboxError(f"exec failed: {type(e).__name__}: {e}") from e
|
| 146 |
+
|
| 147 |
+
OptimizerCls = locs.get("Optimizer") or globs.get("Optimizer")
|
| 148 |
+
if OptimizerCls is None:
|
| 149 |
+
raise SandboxError("Optimizer class not defined after exec")
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
with _signal_timeout(init_timeout):
|
| 153 |
+
instance = OptimizerCls(dim=dim)
|
| 154 |
+
except Exception as e:
|
| 155 |
+
raise SandboxError(f"__init__ failed: {type(e).__name__}: {e}") from e
|
| 156 |
+
|
| 157 |
+
if not hasattr(instance, "step"):
|
| 158 |
+
raise SandboxError("Optimizer instance missing `step` method")
|
| 159 |
+
|
| 160 |
+
return CompiledOptimizer(instance=instance, step_timeout=step_timeout)
|
server/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Landscapeforge environment server components."""
|
| 8 |
+
|
| 9 |
+
from .landscapeforge_environment import LandscapeforgeEnvironment
|
| 10 |
+
|
| 11 |
+
__all__ = ["LandscapeforgeEnvironment"]
|
server/app.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
FastAPI application for the Landscapeforge Environment.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP server that exposes the LandscapeforgeEnvironment
|
| 11 |
+
over HTTP and WebSocket endpoints, compatible with EnvClient.
|
| 12 |
+
|
| 13 |
+
Endpoints:
|
| 14 |
+
- POST /reset: Reset the environment
|
| 15 |
+
- POST /step: Execute an action
|
| 16 |
+
- GET /state: Get current environment state
|
| 17 |
+
- GET /schema: Get action/observation schemas
|
| 18 |
+
- WS /ws: WebSocket endpoint for persistent sessions
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
# Development (with auto-reload):
|
| 22 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 23 |
+
|
| 24 |
+
# Production:
|
| 25 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 26 |
+
|
| 27 |
+
# Or run directly:
|
| 28 |
+
python -m server.app
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from openenv.core.env_server.http_server import create_app
|
| 33 |
+
except Exception as e: # pragma: no cover
|
| 34 |
+
raise ImportError(
|
| 35 |
+
"openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
|
| 36 |
+
) from e
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from ..models import LandscapeforgeAction, LandscapeforgeObservation
|
| 40 |
+
from .landscapeforge_environment import LandscapeforgeEnvironment
|
| 41 |
+
from ..demo.ui import build_ui as _build_demo_ui
|
| 42 |
+
except ModuleNotFoundError:
|
| 43 |
+
from models import LandscapeforgeAction, LandscapeforgeObservation
|
| 44 |
+
from server.landscapeforge_environment import LandscapeforgeEnvironment
|
| 45 |
+
from demo.ui import build_ui as _build_demo_ui
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# Create the core FastAPI app (without OpenEnv's built-in web UI, which has a
|
| 49 |
+
# theme-kwarg incompatibility with Gradio 5.x). We mount our custom Gradio
|
| 50 |
+
# demo manually at /web below.
|
| 51 |
+
app = create_app(
|
| 52 |
+
LandscapeforgeEnvironment,
|
| 53 |
+
LandscapeforgeAction,
|
| 54 |
+
LandscapeforgeObservation,
|
| 55 |
+
env_name="landscapeforge",
|
| 56 |
+
max_concurrent_envs=4,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Mount Gradio demo at /web
|
| 60 |
+
try:
|
| 61 |
+
import gradio as gr
|
| 62 |
+
_demo = _build_demo_ui()
|
| 63 |
+
app = gr.mount_gradio_app(app, _demo, path="/web")
|
| 64 |
+
except Exception as _e: # pragma: no cover
|
| 65 |
+
import logging
|
| 66 |
+
logging.getLogger(__name__).warning(
|
| 67 |
+
"Gradio demo failed to mount (%s); FastAPI endpoints still available.", _e,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main():
|
| 72 |
+
"""Entry point for direct execution.
|
| 73 |
+
|
| 74 |
+
Parses --host / --port from the command line (also honours $PORT),
|
| 75 |
+
defaulting to 0.0.0.0:8000 for container-friendly launches.
|
| 76 |
+
"""
|
| 77 |
+
import argparse
|
| 78 |
+
import os
|
| 79 |
+
import uvicorn
|
| 80 |
+
|
| 81 |
+
parser = argparse.ArgumentParser()
|
| 82 |
+
parser.add_argument("--port", type=int,
|
| 83 |
+
default=int(os.environ.get("PORT", 8000)))
|
| 84 |
+
parser.add_argument("--host", type=str, default="0.0.0.0")
|
| 85 |
+
args = parser.parse_args()
|
| 86 |
+
uvicorn.run(app, host=args.host, port=args.port)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
main()
|
server/landscapeforge_environment.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LandscapeForge OpenEnv environment — OptCoder REPL (Phase C).
|
| 2 |
+
|
| 3 |
+
For v1 we ship OptCoder-only: LandscapeForge is a fixed template picker
|
| 4 |
+
controlled by the env itself (uniform random over the tier menu). The agent
|
| 5 |
+
acting through OpenEnv is OptCoder.
|
| 6 |
+
|
| 7 |
+
Each `reset()` samples a new landscape from the current tier. Each `step()`
|
| 8 |
+
executes one OptCoder action (run_baseline / draft / inspect / commit),
|
| 9 |
+
mutates env state, and returns an Observation reflecting the new state.
|
| 10 |
+
Episode ends when OptCoder commits or budget is exhausted.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from typing import Any, Optional
|
| 16 |
+
from uuid import uuid4
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
from openenv.core.env_server.interfaces import Environment
|
| 20 |
+
from openenv.core.env_server.types import State
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
from ..models import (
|
| 24 |
+
ACTION_COSTS,
|
| 25 |
+
LandscapeforgeAction,
|
| 26 |
+
LandscapeforgeObservation,
|
| 27 |
+
)
|
| 28 |
+
from ..landscapes import (
|
| 29 |
+
TIER_MENU,
|
| 30 |
+
Landscape,
|
| 31 |
+
build_landscape,
|
| 32 |
+
structural_hints,
|
| 33 |
+
)
|
| 34 |
+
from ..reference_optimizers import run_baseline as run_reference_baseline
|
| 35 |
+
from ..reference_optimizers import tune_adam_lr
|
| 36 |
+
from ..sandbox import SandboxError, compile_optimizer
|
| 37 |
+
from ..arena import ArenaResult, auto_test_draft, run_arena
|
| 38 |
+
from ..rewards import ast_novelty_score, compute_optcoder_reward, compute_step_reward
|
| 39 |
+
except ImportError:
|
| 40 |
+
# Running from repo root or package layout quirks
|
| 41 |
+
from models import ( # type: ignore
|
| 42 |
+
ACTION_COSTS,
|
| 43 |
+
LandscapeforgeAction,
|
| 44 |
+
LandscapeforgeObservation,
|
| 45 |
+
)
|
| 46 |
+
from landscapes import ( # type: ignore
|
| 47 |
+
TIER_MENU,
|
| 48 |
+
Landscape,
|
| 49 |
+
build_landscape,
|
| 50 |
+
structural_hints,
|
| 51 |
+
)
|
| 52 |
+
from reference_optimizers import run_baseline as run_reference_baseline # type: ignore
|
| 53 |
+
from reference_optimizers import tune_adam_lr # type: ignore
|
| 54 |
+
from sandbox import SandboxError, compile_optimizer # type: ignore
|
| 55 |
+
from arena import ArenaResult, auto_test_draft, run_arena # type: ignore
|
| 56 |
+
from rewards import ast_novelty_score, compute_optcoder_reward, compute_step_reward # type: ignore
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
BUDGET_TOTAL = 12
|
| 60 |
+
ARENA_SEEDS = [101, 202, 303, 404, 505, 606, 707, 808, 909, 1010]
|
| 61 |
+
ARENA_STEPS = 200
|
| 62 |
+
BASELINE_STEPS = 30 # env-controlled; agent does not choose
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Reference source blobs for AST novelty comparison (short pseudo-implementations).
|
| 66 |
+
# Kept minimal — enough to detect "this commit is basically Adam".
|
| 67 |
+
_REF_SGD = """
|
| 68 |
+
class Optimizer:
|
| 69 |
+
def __init__(self, dim): self.lr = 0.01
|
| 70 |
+
def step(self, x, f, g): return x - self.lr * g
|
| 71 |
+
""".strip()
|
| 72 |
+
|
| 73 |
+
def _adam_source(lr: float) -> str:
|
| 74 |
+
"""Adam reference implementation parameterized by LR.
|
| 75 |
+
|
| 76 |
+
Used by `_ensure_adam_arena` after LR tuning — the baseline is
|
| 77 |
+
Adam-at-best-LR-for-this-landscape, not Adam-at-fixed-default.
|
| 78 |
+
"""
|
| 79 |
+
return f"""
|
| 80 |
+
class Optimizer:
|
| 81 |
+
def __init__(self, dim):
|
| 82 |
+
self.lr = {lr}
|
| 83 |
+
self.b1 = 0.9
|
| 84 |
+
self.b2 = 0.999
|
| 85 |
+
self.eps = 1e-8
|
| 86 |
+
self.m = np.zeros(dim)
|
| 87 |
+
self.v = np.zeros(dim)
|
| 88 |
+
self.t = 0
|
| 89 |
+
def step(self, x, f_val, g):
|
| 90 |
+
self.t += 1
|
| 91 |
+
self.m = self.b1*self.m + (1-self.b1)*g
|
| 92 |
+
self.v = self.b2*self.v + (1-self.b2)*g*g
|
| 93 |
+
mh = self.m/(1-self.b1**self.t)
|
| 94 |
+
vh = self.v/(1-self.b2**self.t)
|
| 95 |
+
return x - self.lr * mh / (np.sqrt(vh) + self.eps)
|
| 96 |
+
""".strip()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# Frozen default-LR source used only for AST-novelty comparison (so r_novelty
|
| 100 |
+
# measures "structurally different from Adam" regardless of the tuned LR).
|
| 101 |
+
_REF_ADAM = _adam_source(0.001)
|
| 102 |
+
|
| 103 |
+
_REF_MOMENTUM = """
|
| 104 |
+
class Optimizer:
|
| 105 |
+
def __init__(self, dim):
|
| 106 |
+
import numpy as np
|
| 107 |
+
self.lr=0.01; self.beta=0.9; self.v = np.zeros(dim)
|
| 108 |
+
def step(self, x, f, g):
|
| 109 |
+
self.v = self.beta*self.v - self.lr*g
|
| 110 |
+
return x + self.v
|
| 111 |
+
""".strip()
|
| 112 |
+
|
| 113 |
+
REFERENCE_SOURCES = [_REF_SGD, _REF_ADAM, _REF_MOMENTUM]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class LandscapeforgeEnvironment(Environment):
|
| 117 |
+
"""OptCoder-facing OpenEnv environment.
|
| 118 |
+
|
| 119 |
+
LandscapeForge is internal (template picker) in v1.
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
SUPPORTS_CONCURRENT_SESSIONS: bool = True
|
| 123 |
+
|
| 124 |
+
def __init__(self, tier: str = "T0", seed: int = 0):
|
| 125 |
+
self._initial_tier = tier
|
| 126 |
+
self._master_rng = np.random.default_rng(seed)
|
| 127 |
+
self._reset_count = 0
|
| 128 |
+
self._tier = tier
|
| 129 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 130 |
+
# Populated by reset()
|
| 131 |
+
self._landscape: Optional[Landscape] = None
|
| 132 |
+
self._hints: dict = {}
|
| 133 |
+
self._baseline_history: list[dict] = []
|
| 134 |
+
self._draft_history: list[dict] = []
|
| 135 |
+
self._draft_details: list[list[dict]] = [] # per-draft per-step detail
|
| 136 |
+
self._inspect_requests: list[dict] = []
|
| 137 |
+
self._current_draft: Optional[str] = None
|
| 138 |
+
self._budget_spent: int = 0
|
| 139 |
+
self._committed: bool = False
|
| 140 |
+
self._final_obs: Optional[LandscapeforgeObservation] = None
|
| 141 |
+
# Cache Adam's full arena result per episode (computed lazily, for
|
| 142 |
+
# reward normalization via progress-based r_regret). The baseline is
|
| 143 |
+
# Adam-at-tuned-LR — per-landscape LR is selected via a short sweep.
|
| 144 |
+
self._adam_arena_cache: Optional[ArenaResult] = None
|
| 145 |
+
self._adam_tuned_lr: Optional[float] = None
|
| 146 |
+
# Stepwise feedback log (PBS delta + compile penalty). This is shown to
|
| 147 |
+
# the LLM in the observation so it can course-correct mid-episode, but
|
| 148 |
+
# NEVER added to the training scalar — final reward is purely terminal
|
| 149 |
+
# arena reward (§9.1) for robustness against reward hacking.
|
| 150 |
+
self._step_feedback_log: list[dict] = []
|
| 151 |
+
|
| 152 |
+
# ---------- OpenEnv API ----------
|
| 153 |
+
|
| 154 |
+
def reset(self) -> LandscapeforgeObservation:
|
| 155 |
+
self._reset_count += 1
|
| 156 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 157 |
+
|
| 158 |
+
# Pick a landscape from the current tier's menu.
|
| 159 |
+
menu = TIER_MENU[self._tier]
|
| 160 |
+
template = str(self._master_rng.choice(menu))
|
| 161 |
+
dim = int(self._master_rng.integers(2, 6)) # small dims for v1
|
| 162 |
+
params = self._sample_params(template)
|
| 163 |
+
self._landscape = build_landscape(
|
| 164 |
+
template=template, dim=dim, params=params,
|
| 165 |
+
rng=np.random.default_rng(int(self._master_rng.integers(0, 2**31))),
|
| 166 |
+
)
|
| 167 |
+
self._hints = structural_hints(
|
| 168 |
+
self._landscape,
|
| 169 |
+
rng=np.random.default_rng(int(self._master_rng.integers(0, 2**31))),
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# Wipe REPL state
|
| 173 |
+
self._baseline_history = []
|
| 174 |
+
self._draft_history = []
|
| 175 |
+
self._draft_details = []
|
| 176 |
+
self._inspect_requests = []
|
| 177 |
+
self._current_draft = None
|
| 178 |
+
self._budget_spent = 0
|
| 179 |
+
self._committed = False
|
| 180 |
+
self._final_obs = None
|
| 181 |
+
self._adam_arena_cache = None
|
| 182 |
+
self._adam_tuned_lr = None
|
| 183 |
+
self._step_feedback_log = []
|
| 184 |
+
|
| 185 |
+
return self._make_observation(
|
| 186 |
+
last_kind=None, last_result={"reset": True}, done=False, reward=0.0,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
def step(self, action: LandscapeforgeAction) -> LandscapeforgeObservation: # type: ignore[override]
|
| 190 |
+
if self._landscape is None:
|
| 191 |
+
raise RuntimeError("step() called before reset()")
|
| 192 |
+
if self._committed:
|
| 193 |
+
# Episode already done; return terminal obs.
|
| 194 |
+
assert self._final_obs is not None
|
| 195 |
+
return self._final_obs
|
| 196 |
+
|
| 197 |
+
self._state.step_count += 1
|
| 198 |
+
cost = ACTION_COSTS[action.kind]
|
| 199 |
+
# Charge budget first so over-limit actions are rejected.
|
| 200 |
+
if self._budget_spent + cost > BUDGET_TOTAL and action.kind != "commit":
|
| 201 |
+
return self._force_commit(reason="budget_exhausted")
|
| 202 |
+
|
| 203 |
+
self._budget_spent += cost
|
| 204 |
+
|
| 205 |
+
# Snapshot draft history for PBS computation
|
| 206 |
+
prev_draft_history_snapshot = list(self._draft_history)
|
| 207 |
+
|
| 208 |
+
if action.kind == "run_baseline":
|
| 209 |
+
result = self._do_run_baseline(action)
|
| 210 |
+
elif action.kind == "draft":
|
| 211 |
+
result = self._do_draft(action)
|
| 212 |
+
elif action.kind == "inspect":
|
| 213 |
+
result = self._do_inspect(action)
|
| 214 |
+
elif action.kind == "commit":
|
| 215 |
+
return self._do_commit()
|
| 216 |
+
else:
|
| 217 |
+
raise ValueError(f"Unknown action kind: {action.kind}")
|
| 218 |
+
|
| 219 |
+
# Compute stepwise FEEDBACK (NOT reward). Signals the LLM can use to
|
| 220 |
+
# course-correct mid-episode — exposed through last_action_result.
|
| 221 |
+
# Explicitly NOT summed into training reward; terminal arena reward
|
| 222 |
+
# is the only signal GRPO sees (robust against reward hacking).
|
| 223 |
+
step_feedback = compute_step_reward(
|
| 224 |
+
prev_draft_history=prev_draft_history_snapshot,
|
| 225 |
+
new_draft_history=self._draft_history,
|
| 226 |
+
action_kind=action.kind,
|
| 227 |
+
action_result=result,
|
| 228 |
+
)
|
| 229 |
+
if step_feedback["breakdown"]:
|
| 230 |
+
entry = {
|
| 231 |
+
"turn": self._state.step_count,
|
| 232 |
+
"action_kind": action.kind,
|
| 233 |
+
**step_feedback["breakdown"],
|
| 234 |
+
}
|
| 235 |
+
self._step_feedback_log.append(entry)
|
| 236 |
+
# Surface on this turn's action result so the LLM sees it immediately.
|
| 237 |
+
result = {**result, "feedback": step_feedback["breakdown"]}
|
| 238 |
+
|
| 239 |
+
# Check if budget now exhausted; if so, auto-commit.
|
| 240 |
+
if self._budget_spent >= BUDGET_TOTAL:
|
| 241 |
+
return self._force_commit(reason="budget_exhausted")
|
| 242 |
+
|
| 243 |
+
return self._make_observation(
|
| 244 |
+
last_kind=action.kind, last_result=result,
|
| 245 |
+
done=False, reward=0.0, # no reward on non-terminal steps
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
@property
|
| 249 |
+
def state(self) -> State:
|
| 250 |
+
return self._state
|
| 251 |
+
|
| 252 |
+
# ---------- Action handlers ----------
|
| 253 |
+
|
| 254 |
+
def _do_run_baseline(self, action: LandscapeforgeAction) -> dict:
|
| 255 |
+
assert self._landscape is not None
|
| 256 |
+
# Fixed init AND fixed step count for baseline comparability across
|
| 257 |
+
# episodes and rollouts (important for GRPO group-relative advantages).
|
| 258 |
+
rng = np.random.default_rng(42)
|
| 259 |
+
x0 = rng.normal(0.0, 0.5, size=self._landscape.dim)
|
| 260 |
+
result = run_reference_baseline(
|
| 261 |
+
name=action.baseline_name, f=self._landscape.f, grad=self._landscape.grad,
|
| 262 |
+
x0=x0, steps=BASELINE_STEPS,
|
| 263 |
+
)
|
| 264 |
+
self._baseline_history.append(result)
|
| 265 |
+
return {
|
| 266 |
+
"baseline_index": len(self._baseline_history) - 1,
|
| 267 |
+
"name": result["name"],
|
| 268 |
+
"n_steps": len(result["trajectory"]),
|
| 269 |
+
"final_f": (result["trajectory"][-1]["f"]
|
| 270 |
+
if result["trajectory"] and result["trajectory"][-1]["f"] is not None
|
| 271 |
+
else None),
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
def _do_draft(self, action: LandscapeforgeAction) -> dict:
|
| 275 |
+
assert self._landscape is not None
|
| 276 |
+
code = action.code or ""
|
| 277 |
+
self._current_draft = code
|
| 278 |
+
try:
|
| 279 |
+
opt = compile_optimizer(code, dim=self._landscape.dim)
|
| 280 |
+
except SandboxError as e:
|
| 281 |
+
# Record failed draft; still counts toward history for inspect.
|
| 282 |
+
self._draft_history.append({
|
| 283 |
+
"code": code,
|
| 284 |
+
"compile_error": str(e),
|
| 285 |
+
"summary": {"converged": False, "diverged": True, "error": str(e),
|
| 286 |
+
"final_f": None, "step_of_min": None, "min_f": None},
|
| 287 |
+
})
|
| 288 |
+
self._draft_details.append([])
|
| 289 |
+
return {"draft_index": len(self._draft_history) - 1,
|
| 290 |
+
"compile_error": str(e), "summary": None}
|
| 291 |
+
|
| 292 |
+
test = auto_test_draft(opt, self._landscape, seed=0, steps=20)
|
| 293 |
+
self._draft_history.append({
|
| 294 |
+
"code": code,
|
| 295 |
+
"compile_error": None,
|
| 296 |
+
"summary": test["summary"],
|
| 297 |
+
})
|
| 298 |
+
self._draft_details.append(test["detail"])
|
| 299 |
+
return {"draft_index": len(self._draft_history) - 1,
|
| 300 |
+
"compile_error": None, "summary": test["summary"]}
|
| 301 |
+
|
| 302 |
+
def _do_inspect(self, action: LandscapeforgeAction) -> dict:
|
| 303 |
+
idx = action.draft_idx
|
| 304 |
+
if idx is None or idx < 0 or idx >= len(self._draft_details):
|
| 305 |
+
return {"error": f"draft_idx {idx} out of range (have {len(self._draft_details)} drafts)"}
|
| 306 |
+
detail = self._draft_details[idx]
|
| 307 |
+
start = action.step_range_start
|
| 308 |
+
end = min(action.step_range_end, len(detail))
|
| 309 |
+
sliced = detail[start:end]
|
| 310 |
+
record = {
|
| 311 |
+
"draft_idx": idx,
|
| 312 |
+
"step_range": [start, end],
|
| 313 |
+
"detail": sliced,
|
| 314 |
+
}
|
| 315 |
+
self._inspect_requests.append(record)
|
| 316 |
+
return {"draft_idx": idx, "step_range": [start, end], "n_steps": len(sliced)}
|
| 317 |
+
|
| 318 |
+
def _do_commit(self) -> LandscapeforgeObservation:
|
| 319 |
+
return self._finalize_episode(reason="commit")
|
| 320 |
+
|
| 321 |
+
def _force_commit(self, reason: str) -> LandscapeforgeObservation:
|
| 322 |
+
return self._finalize_episode(reason=reason)
|
| 323 |
+
|
| 324 |
+
# ---------- Episode finalization ----------
|
| 325 |
+
|
| 326 |
+
def _finalize_episode(self, reason: str) -> LandscapeforgeObservation:
|
| 327 |
+
assert self._landscape is not None
|
| 328 |
+
self._committed = True
|
| 329 |
+
|
| 330 |
+
# Need a current_draft. If none, produce a worst-case result.
|
| 331 |
+
if not self._current_draft:
|
| 332 |
+
result = {
|
| 333 |
+
"reason": reason,
|
| 334 |
+
"no_draft": True,
|
| 335 |
+
"final_regret": 1.0,
|
| 336 |
+
}
|
| 337 |
+
r_total = -1.0
|
| 338 |
+
breakdown = {"no_draft": 1.0}
|
| 339 |
+
obs = self._make_observation(
|
| 340 |
+
last_kind="commit", last_result=result,
|
| 341 |
+
done=True, reward=r_total,
|
| 342 |
+
)
|
| 343 |
+
obs.committed = True
|
| 344 |
+
obs.final_regret = 1.0
|
| 345 |
+
obs.r_optcoder = r_total
|
| 346 |
+
obs.r_optcoder_breakdown = breakdown
|
| 347 |
+
self._final_obs = obs
|
| 348 |
+
return obs
|
| 349 |
+
|
| 350 |
+
# Full Phase-D arena eval
|
| 351 |
+
try:
|
| 352 |
+
opt = compile_optimizer(self._current_draft, dim=self._landscape.dim)
|
| 353 |
+
arena = run_arena(opt, self._landscape, seeds=ARENA_SEEDS, steps=ARENA_STEPS)
|
| 354 |
+
except SandboxError as e:
|
| 355 |
+
# Committed code fails to compile -> worst-case result
|
| 356 |
+
arena = ArenaResult(
|
| 357 |
+
initial_values=[1.0] * len(ARENA_SEEDS),
|
| 358 |
+
final_values=[float("nan")] * len(ARENA_SEEDS),
|
| 359 |
+
crashed=[True] * len(ARENA_SEEDS),
|
| 360 |
+
trajectories=[[] for _ in ARENA_SEEDS],
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
# Adam baseline arena for normalization (always run for reward stability).
|
| 364 |
+
adam_arena = self._ensure_adam_arena()
|
| 365 |
+
|
| 366 |
+
novelty = ast_novelty_score(self._current_draft, REFERENCE_SOURCES)
|
| 367 |
+
# Convergence step: first seed's trajectory, first step where f < 0.01 * f0
|
| 368 |
+
convergence_step = self._compute_convergence_step(arena)
|
| 369 |
+
|
| 370 |
+
reward = compute_optcoder_reward(
|
| 371 |
+
arena=arena,
|
| 372 |
+
adam_arena=adam_arena,
|
| 373 |
+
actions_used_cost=self._budget_spent,
|
| 374 |
+
budget_total=BUDGET_TOTAL,
|
| 375 |
+
novelty_score=novelty,
|
| 376 |
+
convergence_step=convergence_step,
|
| 377 |
+
arena_steps=ARENA_STEPS,
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
result = {
|
| 381 |
+
"reason": reason,
|
| 382 |
+
"my_mean_progress": arena.mean_progress,
|
| 383 |
+
"adam_mean_progress": adam_arena.mean_progress,
|
| 384 |
+
"adam_tuned_lr": self._adam_tuned_lr,
|
| 385 |
+
"speedup_vs_adam": reward.breakdown.get("speedup_vs_adam"),
|
| 386 |
+
"crash_fraction": arena.crash_fraction,
|
| 387 |
+
"novelty_score": novelty,
|
| 388 |
+
"convergence_step": convergence_step,
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
obs = self._make_observation(
|
| 392 |
+
last_kind="commit", last_result=result,
|
| 393 |
+
done=True, reward=reward.r_total,
|
| 394 |
+
)
|
| 395 |
+
obs.committed = True
|
| 396 |
+
# `final_regret` is reinterpreted (no f_min dependency): Adam-shortfall
|
| 397 |
+
# in [0, 1]. 0 = matched or beat Adam's descent; 1 = made zero progress
|
| 398 |
+
# while Adam descended normally. Capped at 1.
|
| 399 |
+
speedup = reward.breakdown.get("speedup_vs_adam", 0.0)
|
| 400 |
+
obs.final_regret = float(max(0.0, min(1.0, 1.0 - speedup)))
|
| 401 |
+
obs.r_optcoder = reward.r_total
|
| 402 |
+
obs.r_optcoder_breakdown = reward.breakdown
|
| 403 |
+
self._final_obs = obs
|
| 404 |
+
return obs
|
| 405 |
+
|
| 406 |
+
# ---------- Helpers ----------
|
| 407 |
+
|
| 408 |
+
def _make_observation(self, last_kind: Optional[str], last_result: dict,
|
| 409 |
+
done: bool, reward: float) -> LandscapeforgeObservation:
|
| 410 |
+
assert self._landscape is not None
|
| 411 |
+
return LandscapeforgeObservation(
|
| 412 |
+
landscape_description=self._landscape.description,
|
| 413 |
+
dim=self._landscape.dim,
|
| 414 |
+
structural_hints=self._hints,
|
| 415 |
+
baseline_history=self._serialize_baseline_history(),
|
| 416 |
+
draft_history=self._serialize_draft_history(),
|
| 417 |
+
inspect_requests=list(self._inspect_requests),
|
| 418 |
+
current_draft=self._current_draft,
|
| 419 |
+
budget_remaining=BUDGET_TOTAL - self._budget_spent,
|
| 420 |
+
last_action_kind=last_kind,
|
| 421 |
+
last_action_result=last_result,
|
| 422 |
+
done=done,
|
| 423 |
+
reward=reward,
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
def _serialize_baseline_history(self) -> list[dict]:
|
| 427 |
+
# Trim trajectory to summary-friendly size (every step, x as list).
|
| 428 |
+
return [
|
| 429 |
+
{"name": b["name"], "trajectory": b["trajectory"]}
|
| 430 |
+
for b in self._baseline_history
|
| 431 |
+
]
|
| 432 |
+
|
| 433 |
+
def _serialize_draft_history(self) -> list[dict]:
|
| 434 |
+
# For the observation we include code + summary per draft.
|
| 435 |
+
return [
|
| 436 |
+
{"code": d["code"], "summary": d["summary"], "compile_error": d["compile_error"]}
|
| 437 |
+
for d in self._draft_history
|
| 438 |
+
]
|
| 439 |
+
|
| 440 |
+
def _sample_params(self, template: str) -> dict:
|
| 441 |
+
rng = self._master_rng
|
| 442 |
+
if template == "quadratic":
|
| 443 |
+
# T0 uses cond up to 100; T1 up to 1000; T2 higher.
|
| 444 |
+
cap = {"T0": 100.0, "T1": 1000.0, "T2": 10_000.0}[self._tier]
|
| 445 |
+
return {"cond": float(rng.uniform(1.0, cap))}
|
| 446 |
+
if template == "gaussian_mix":
|
| 447 |
+
return {
|
| 448 |
+
"k": int(rng.integers(2, 6)),
|
| 449 |
+
"sigma": float(rng.uniform(0.3, 1.0)),
|
| 450 |
+
"spread": float(rng.uniform(1.0, 4.0)),
|
| 451 |
+
}
|
| 452 |
+
if template == "huber":
|
| 453 |
+
return {"delta": float(rng.uniform(0.5, 2.0))}
|
| 454 |
+
return {}
|
| 455 |
+
|
| 456 |
+
def _ensure_adam_arena(self) -> ArenaResult:
|
| 457 |
+
"""Build the Adam baseline, FAIRLY — LR is tuned per landscape before
|
| 458 |
+
running the arena. The tuning uses a short 30-step sweep on a dedicated
|
| 459 |
+
seed (not one of the arena seeds) to avoid overfitting.
|
| 460 |
+
|
| 461 |
+
Cached per episode in `_adam_arena_cache`. Tuned LR is stored in
|
| 462 |
+
`_adam_tuned_lr` for logging / demo surfacing.
|
| 463 |
+
"""
|
| 464 |
+
if self._adam_arena_cache is not None:
|
| 465 |
+
return self._adam_arena_cache
|
| 466 |
+
assert self._landscape is not None
|
| 467 |
+
try:
|
| 468 |
+
# Tune LR on seed 0 (not in ARENA_SEEDS), 30-step sweep.
|
| 469 |
+
tune_rng = np.random.default_rng(0)
|
| 470 |
+
tune_x0 = tune_rng.normal(0.0, 0.5, size=self._landscape.dim)
|
| 471 |
+
best_lr = tune_adam_lr(
|
| 472 |
+
f=self._landscape.f, grad=self._landscape.grad,
|
| 473 |
+
x0=tune_x0, sweep_steps=30,
|
| 474 |
+
)
|
| 475 |
+
self._adam_tuned_lr = best_lr
|
| 476 |
+
|
| 477 |
+
adam_opt = compile_optimizer(_adam_source(best_lr), dim=self._landscape.dim)
|
| 478 |
+
self._adam_arena_cache = run_arena(
|
| 479 |
+
adam_opt, self._landscape,
|
| 480 |
+
seeds=ARENA_SEEDS, steps=ARENA_STEPS,
|
| 481 |
+
)
|
| 482 |
+
except Exception:
|
| 483 |
+
self._adam_tuned_lr = None
|
| 484 |
+
self._adam_arena_cache = ArenaResult(
|
| 485 |
+
initial_values=[1.0] * len(ARENA_SEEDS),
|
| 486 |
+
final_values=[1.0] * len(ARENA_SEEDS),
|
| 487 |
+
crashed=[True] * len(ARENA_SEEDS),
|
| 488 |
+
trajectories=[[] for _ in ARENA_SEEDS],
|
| 489 |
+
)
|
| 490 |
+
return self._adam_arena_cache
|
| 491 |
+
|
| 492 |
+
def _compute_convergence_step(self, arena) -> Optional[int]:
|
| 493 |
+
"""First step on first seed where f < 1% of initial f."""
|
| 494 |
+
if not arena.trajectories or not arena.trajectories[0]:
|
| 495 |
+
return None
|
| 496 |
+
traj = arena.trajectories[0]
|
| 497 |
+
if not traj:
|
| 498 |
+
return None
|
| 499 |
+
f0 = traj[0]["f"]
|
| 500 |
+
if f0 <= 0:
|
| 501 |
+
return None
|
| 502 |
+
threshold = 0.01 * f0
|
| 503 |
+
for t, snap in enumerate(traj):
|
| 504 |
+
if snap["f"] < threshold:
|
| 505 |
+
return t
|
| 506 |
+
return None
|
| 507 |
+
|
| 508 |
+
# ---------- Tier advancement API (used by trainer, not agent) ----------
|
| 509 |
+
|
| 510 |
+
def advance_tier(self, new_tier: str) -> None:
|
| 511 |
+
if new_tier not in TIER_MENU:
|
| 512 |
+
raise ValueError(f"Unknown tier {new_tier}")
|
| 513 |
+
self._tier = new_tier
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv[core]>=0.2.0
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_episode.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end smoke test: scripted episode, in-process, no server.
|
| 2 |
+
|
| 3 |
+
Runs: run_baseline(adam) -> draft(Adam-ish) -> inspect -> draft(SGD+momentum)
|
| 4 |
+
-> commit, and verifies the env threads state correctly and produces a
|
| 5 |
+
finite reward.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# Allow running directly: `python tests/test_episode.py`
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
| 15 |
+
|
| 16 |
+
from landscapeforge.models import LandscapeforgeAction # type: ignore
|
| 17 |
+
from landscapeforge.server.landscapeforge_environment import ( # type: ignore
|
| 18 |
+
LandscapeforgeEnvironment,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
ADAM_CODE = """
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
class Optimizer:
|
| 26 |
+
def __init__(self, dim):
|
| 27 |
+
self.lr = 1e-3
|
| 28 |
+
self.b1 = 0.9
|
| 29 |
+
self.b2 = 0.999
|
| 30 |
+
self.eps = 1e-8
|
| 31 |
+
self.m = np.zeros(dim)
|
| 32 |
+
self.v = np.zeros(dim)
|
| 33 |
+
self.t = 0
|
| 34 |
+
|
| 35 |
+
def step(self, x, f_val, grad):
|
| 36 |
+
self.t += 1
|
| 37 |
+
self.m = self.b1 * self.m + (1 - self.b1) * grad
|
| 38 |
+
self.v = self.b2 * self.v + (1 - self.b2) * grad * grad
|
| 39 |
+
m_hat = self.m / (1 - self.b1 ** self.t)
|
| 40 |
+
v_hat = self.v / (1 - self.b2 ** self.t)
|
| 41 |
+
return x - self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
SGDM_CODE = """
|
| 45 |
+
import numpy as np
|
| 46 |
+
|
| 47 |
+
class Optimizer:
|
| 48 |
+
def __init__(self, dim):
|
| 49 |
+
self.lr = 0.05
|
| 50 |
+
self.beta = 0.9
|
| 51 |
+
self.v = np.zeros(dim)
|
| 52 |
+
|
| 53 |
+
def step(self, x, f_val, grad):
|
| 54 |
+
self.v = self.beta * self.v - self.lr * grad
|
| 55 |
+
return x + self.v
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def scripted_episode() -> None:
|
| 60 |
+
env = LandscapeforgeEnvironment(tier="T0", seed=42)
|
| 61 |
+
obs = env.reset()
|
| 62 |
+
print(f"[reset] landscape: {obs.landscape_description}")
|
| 63 |
+
print(f" dim={obs.dim}, hints={obs.structural_hints}")
|
| 64 |
+
print(f" budget={obs.budget_remaining}")
|
| 65 |
+
|
| 66 |
+
# 1. Run Adam baseline to see what it does.
|
| 67 |
+
obs = env.step(LandscapeforgeAction(
|
| 68 |
+
kind="run_baseline", baseline_name="adam",
|
| 69 |
+
))
|
| 70 |
+
print(f"\n[run_baseline adam] result={obs.last_action_result}")
|
| 71 |
+
print(f" budget_remaining={obs.budget_remaining}")
|
| 72 |
+
|
| 73 |
+
# 2. Submit an Adam draft.
|
| 74 |
+
obs = env.step(LandscapeforgeAction(kind="draft", code=ADAM_CODE))
|
| 75 |
+
print(f"\n[draft adam] compile_error={obs.last_action_result.get('compile_error')}")
|
| 76 |
+
print(f" summary={obs.last_action_result.get('summary')}")
|
| 77 |
+
print(f" budget_remaining={obs.budget_remaining}")
|
| 78 |
+
|
| 79 |
+
# 3. Inspect the first draft.
|
| 80 |
+
obs = env.step(LandscapeforgeAction(
|
| 81 |
+
kind="inspect", draft_idx=0, step_range_start=10, step_range_end=20,
|
| 82 |
+
))
|
| 83 |
+
print(f"\n[inspect 0 steps 10-20] result={obs.last_action_result}")
|
| 84 |
+
print(f" budget_remaining={obs.budget_remaining}")
|
| 85 |
+
|
| 86 |
+
# 4. Submit an SGD+momentum alternative.
|
| 87 |
+
obs = env.step(LandscapeforgeAction(kind="draft", code=SGDM_CODE))
|
| 88 |
+
print(f"\n[draft sgdm] compile_error={obs.last_action_result.get('compile_error')}")
|
| 89 |
+
print(f" summary={obs.last_action_result.get('summary')}")
|
| 90 |
+
print(f" budget_remaining={obs.budget_remaining}")
|
| 91 |
+
|
| 92 |
+
# 5. Commit.
|
| 93 |
+
obs = env.step(LandscapeforgeAction(kind="commit"))
|
| 94 |
+
print(f"\n[commit]")
|
| 95 |
+
print(f" done={obs.done}")
|
| 96 |
+
print(f" reward={obs.reward}")
|
| 97 |
+
print(f" final_regret={obs.final_regret}")
|
| 98 |
+
print(f" r_optcoder_breakdown={obs.r_optcoder_breakdown}")
|
| 99 |
+
print(f" last_action_result={obs.last_action_result}")
|
| 100 |
+
|
| 101 |
+
# Sanity checks
|
| 102 |
+
assert obs.done is True, "should be done after commit"
|
| 103 |
+
assert obs.reward is not None, "reward must be produced"
|
| 104 |
+
assert obs.final_regret is not None, "final_regret must be produced"
|
| 105 |
+
assert obs.r_optcoder_breakdown, "breakdown must be populated"
|
| 106 |
+
print("\n✓ scripted_episode PASSED")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def episode_with_broken_code() -> None:
|
| 110 |
+
"""Submitting code that fails to compile should not crash the env."""
|
| 111 |
+
env = LandscapeforgeEnvironment(tier="T0", seed=7)
|
| 112 |
+
env.reset()
|
| 113 |
+
|
| 114 |
+
# Intentional syntax error
|
| 115 |
+
obs = env.step(LandscapeforgeAction(
|
| 116 |
+
kind="draft", code="this is not python",
|
| 117 |
+
))
|
| 118 |
+
print(f"\n[broken draft] compile_error={obs.last_action_result.get('compile_error')}")
|
| 119 |
+
assert obs.last_action_result.get("compile_error") is not None
|
| 120 |
+
assert obs.done is False
|
| 121 |
+
|
| 122 |
+
# Commit with bad code — should produce worst-case regret, not crash
|
| 123 |
+
obs = env.step(LandscapeforgeAction(kind="commit"))
|
| 124 |
+
print(f"[broken commit] reward={obs.reward}, final_regret={obs.final_regret}")
|
| 125 |
+
assert obs.done is True
|
| 126 |
+
assert obs.reward is not None
|
| 127 |
+
print("\n✓ episode_with_broken_code PASSED")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def budget_exhaustion() -> None:
|
| 131 |
+
"""Spamming drafts until budget runs out should auto-commit."""
|
| 132 |
+
env = LandscapeforgeEnvironment(tier="T0", seed=3)
|
| 133 |
+
env.reset()
|
| 134 |
+
|
| 135 |
+
for i in range(10):
|
| 136 |
+
obs = env.step(LandscapeforgeAction(kind="draft", code=ADAM_CODE))
|
| 137 |
+
if obs.done:
|
| 138 |
+
print(f"\n[budget_exhaustion] auto-committed after {i+1} drafts")
|
| 139 |
+
print(f" reason={obs.last_action_result.get('reason')}")
|
| 140 |
+
assert obs.last_action_result.get("reason") == "budget_exhausted"
|
| 141 |
+
print("\n✓ budget_exhaustion PASSED")
|
| 142 |
+
return
|
| 143 |
+
raise AssertionError("Budget never exhausted — shouldn't happen with draft cost 2, budget 12")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
scripted_episode()
|
| 148 |
+
episode_with_broken_code()
|
| 149 |
+
budget_exhaustion()
|
| 150 |
+
print("\nAll tests passed.")
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|