amaljoe88 commited on
Commit
9d4b50c
·
verified ·
1 Parent(s): c882b11

deploy: sync c0cd151f from GitHub Actions

Browse files
Files changed (2) hide show
  1. CLAUDE.md +0 -494
  2. blog.md +0 -214
CLAUDE.md DELETED
@@ -1,494 +0,0 @@
1
- # VisionCoder OpenEnv — Claude Code Guide
2
-
3
- ## Project
4
- Screenshot-to-HTML RL environment for the Scaler x Meta PyTorch Hackathon.
5
- OpenEnv-compatible HTTP API: `reset()` / `step()` / `render()` / `state()`.
6
-
7
- **Round 1** (backup branch `round1`): single-step, single-agent inference.
8
- **Round 2** (current `main`): multi-step iterative environment + multi-agent (Developer + Critic) + RL training.
9
-
10
- ## Package structure
11
- - `src/` — maps to `openenv` package (`client.py`, `models.py`, `agents.py`, `prompts.py`, `inference.py`, `train.py`, `dataset.py`)
12
- - `src/server/` — maps to `openenv.server` (`app.py`, `environment.py`)
13
- - `src/server/rewards/` — maps to `openenv.server.rewards` (one file per reward function)
14
- - `data/` — bundled synthetic samples (5 per difficulty, ~40KB each)
15
- - `data/tests/` — reward stability test cases (0-14; committed HTML + expected scores; renders gitignored)
16
- - `tests/test_rewards.py` — unified test suite (unit + stability + correlation tests)
17
-
18
- ## Running on rmgpu006 (cluster)
19
-
20
- ### Step 1 — start vLLM (tmux session: `vllm`)
21
- ```bash
22
- ~/.local/bin/tmux new-session -s vllm
23
- # inside that session:
24
- apptainer exec --nv ~/apptainer-images/cuda-custom-amal_latest.sif bash -c \
25
- 'export LD_PRELOAD=/dev/shm/qwen35/lib/libstdc++.so.6;
26
- /dev/shm/qwen35/bin/python -m vllm.entrypoints.openai.api_server \
27
- --model ~/models/Qwen3.5-2B --served-model-name qwen35 \
28
- --tensor-parallel-size 2 --port 8001 --host 0.0.0.0 \
29
- --max-model-len 65536 --enable-auto-tool-choice --tool-call-parser hermes' \
30
- 2>&1 | tee ~/vllm_qwen35.log
31
- ```
32
- **`--enable-auto-tool-choice --tool-call-parser hermes` is mandatory** — without it every Developer call fails with 400 Bad Request and falls back to FALLBACK_HTML.
33
-
34
- ### Step 2 — start env server (tmux session: `openenv`, no apptainer needed)
35
- ```bash
36
- ~/.local/bin/tmux new-session -s openenv
37
- # inside that session:
38
- export PLAYWRIGHT_BROWSERS_PATH=~/playwright-browsers
39
- cd ~/workspace/vision-coder-openenv
40
- /dev/shm/qwen35/bin/python -m uvicorn openenv.server.app:app --host 127.0.0.1 --port 18080
41
- ```
42
-
43
- ### Step 3 — run inference (same openenv session or a new window)
44
- ```bash
45
- export API_BASE_URL=http://localhost:8001/v1
46
- export MODEL_NAME=qwen35
47
- export HF_TOKEN=sk-local
48
- export MAX_STEPS=2
49
- export PLAYWRIGHT_BROWSERS_PATH=~/playwright-browsers
50
- cd ~/workspace/vision-coder-openenv
51
- /dev/shm/qwen35/bin/python inference.py
52
- ```
53
-
54
- ### First-time setup
55
- ```bash
56
- # Install package (once per env build)
57
- /dev/shm/qwen35/bin/pip install -e .
58
-
59
- # Download model (once, needs proxy sourced)
60
- source ~/proxy-setup/scripts/proxy_env.sh
61
- /dev/shm/qwen35/bin/python -c "
62
- from huggingface_hub import snapshot_download
63
- snapshot_download('Qwen/Qwen3.5-2B', local_dir='$HOME/models/Qwen3.5-2B')
64
- "
65
- ```
66
-
67
- ## Running locally (generic)
68
- ```bash
69
- pip install -e .
70
- uvicorn openenv.server.app:app --host 0.0.0.0 --port 7860
71
- ```
72
-
73
- ## Running inference (HF router)
74
- ```bash
75
- export API_BASE_URL=https://router.huggingface.co/v1
76
- export MODEL_NAME=Qwen/Qwen3.5-35B-A3B
77
- export HF_TOKEN=hf_...
78
- python inference.py
79
- ```
80
-
81
- ## Environment variables
82
- - `API_BASE_URL` — OpenAI-compatible LLM endpoint (required)
83
- - `MODEL_NAME` — vision-capable model ID (required); Developer and Critic share this endpoint
84
- - `HF_TOKEN` — Hugging Face token / API key for LLM calls (primary auth key)
85
- - `MAX_STEPS` — max developer turns per episode (default: 5)
86
- - `INFERENCE_SERVER_PORT` — env server port (default: 18080)
87
- - `DEBUG` — set to `1` to enable full episode debug logging. Creates `outputs/<run_id>/` with:
88
- - `<difficulty>.md` — per-episode markdown log (reference image, all step renders, HTML, rewards, critic text)
89
- - `images/` — PNGs saved separately (reference, each step's rendered output, critic comparison views)
90
- - Run: `DEBUG=1 API_BASE_URL=... MODEL_NAME=... /dev/shm/qwen35/bin/python inference.py`
91
- - `ONE_SHOT` — removed. Zero-shot outperforms few-shot (mean 0.679 vs 0.653) on this model; few-shot was causing early termination from hallucinated items and content contamination from example pages.
92
-
93
- ## Python version
94
- Use `python3.13` locally (pip maps to python3.13 here, not `python3`).
95
- On rmgpu006: use `/dev/shm/qwen35/bin/python`.
96
-
97
- ---
98
-
99
- ## Reward function
100
-
101
- Composite of 8 sub-rewards, weighted and normalised to [0, 1]:
102
-
103
- | Reward | Weight | What it measures |
104
- |---|---|---|
105
- | `format` | 0.5 | Has ` ```html ` fence + `<!DOCTYPE html>` |
106
- | `validity` | 0.5 | Structural completeness (`html`/`head`/`body`, diverse tags) |
107
- | `structural` | 0.5 | Tag-sequence similarity + inline-style property coverage |
108
- | `text_block` | 3.0 | Hungarian-matched text block IoU + text similarity |
109
- | `position` | 1.0 | Hungarian-matched centroid distance |
110
- | `color` | 1.5 | Spatial CIEDE2000 on reference non-white pixels |
111
- | `clip` | 2.5 | CLIP ViT-B/32 cosine similarity, renormalised (threshold 0.65) |
112
- | `ssim` | 1.5 | Pixel-level SSIM (skimage, 320×240 RGB) — near-perfect zone sensitivity |
113
-
114
- **Weight sum = 11.0.** `format`/`validity`/`structural` reduced (saturate early); `color`/`clip`/`ssim` boosted for continuous near-perfect discrimination.
115
-
116
- **Content multiplier:** applied to the weighted total. If reference has content but prediction is nearly blank (< 0.5% non-white pixels at 32×32), multiplier scales linearly from 0 to 1. Ensures blank predictions score 0.0 even if individual sub-rewards are nonzero.
117
-
118
- **CLIP renormalisation:** raw cosine ≤ 0.65 → score 0; 1.0 → 1.0. Makes blank pages (raw ~0.45) and unstyled pages (raw ~0.76) meaningfully separated.
119
-
120
- **Observed scores on 15 test cases (averages):**
121
- ```
122
- perfect 0.977 minor_diff 0.883 bad_colors 0.740
123
- half_styled 0.524 no_layout 0.469 no_style 0.393 blank 0.000
124
- ```
125
- Global Spearman ρ vs canonical targets = 0.955 (15/15 PASS). Gaps improved: perfect→minor_diff +58%, minor_diff→bad_colors +51% vs old weights.
126
-
127
- ---
128
-
129
- ## Reward stability tests
130
-
131
- Test suite at `tests/test_rewards.py`. Test data in `data/tests/<num>/` (0-14, mapping easy/0-4, medium/0-4, hard/0-4).
132
-
133
- Each case has:
134
- - `reference.html`, `variants/*.html` (7 quality levels) — committed
135
- - `expected_scores.json` — per-case baseline scores — committed
136
- - `renders/` — PNG renders + block JSONs — **gitignored**, auto-generated
137
-
138
- ```bash
139
- # First run on a new machine (needs apptainer for Playwright on rmgpu006)
140
- apptainer exec ~/apptainer-images/cuda-custom-amal_latest.sif bash -c \
141
- 'export PLAYWRIGHT_BROWSERS_PATH=~/playwright-browsers
142
- /dev/shm/qwen35/bin/python tests/test_rewards.py --render'
143
-
144
- # Score only (fast, uses cached renders — works outside apptainer)
145
- /dev/shm/qwen35/bin/python tests/test_rewards.py
146
-
147
- # Re-render specific cases
148
- /dev/shm/qwen35/bin/python tests/test_rewards.py --render --cases 0,1,5
149
-
150
- # After changing reward functions, lock in new baseline
151
- /dev/shm/qwen35/bin/python tests/test_rewards.py --update-expected
152
-
153
- # As pytest (unit tests only, no Playwright needed)
154
- /dev/shm/qwen35/bin/python -m pytest tests/test_rewards.py -v -m "not integration"
155
- ```
156
-
157
- Pass criteria: Spearman ρ ≥ 0.80 per case, global ρ ≥ 0.85, blank ≤ 0.05, perfect ≥ 0.80.
158
-
159
- ---
160
-
161
- ## Round 2 architecture
162
-
163
- ### Models
164
- | Role | Inference (eval) | Training |
165
- |---|---|---|
166
- | Developer | `Qwen/Qwen3.5-35B-A3B` via HF router | `Qwen/Qwen3.5-2B` with LoRA (rank=16) |
167
- | Critic | `Qwen/Qwen3.5-35B-A3B` via HF router | shared 2B base |
168
-
169
- - Qwen3.5 is unified vision+text — no separate VL variant needed
170
- - 2B fits 2×A100 with LoRA; training completes in ~2h for 20 episodes × 4 rollouts
171
- - Run 2 uses `--resume-from checkpoints/run2/developer_final` for continued improvement
172
-
173
- ### Environment changes (server/)
174
- - `reset()` returns task + `session_id`; session state lives in-memory per episode
175
- - `step(html, session_id)` → `{reward, render_low (base64), render_full (base64), done}`
176
- - `render(html)` → `{image (base64)}` — renders only, no reward (used by Developer tool call)
177
- - `done=true` when max steps reached
178
-
179
- ### Multi-agent inference loop (inference.py)
180
- ```
181
- for each task (episode):
182
- state = env.reset() # → session_id, reference_image
183
- code, critique, render_prev = "", None, None
184
-
185
- for step i in range(MAX_STEPS):
186
- # Developer: fast mode, render() tool available
187
- # Input: reference_image (low-res) + code + critique
188
- # Calls render(new_html) mid-generation to self-check
189
- code = developer.generate(ref_image, code, critique)
190
-
191
- result = env.step(code, session_id) # → reward, render_low, render_full, done
192
- log_step(i, code, result.reward, result.done)
193
- if result.done: break
194
-
195
- # Critic: thinking mode on
196
- # Input: reference_image (full-res) + render_{i-1} + critique_{i-1} + render_i
197
- critique = critic.review(ref_image, render_prev, critique, result.render_full)
198
- if "DONE" in critique: break
199
-
200
- render_prev = result.render_full
201
-
202
- log_end(...)
203
- ```
204
-
205
- ### RL training (train.py)
206
-
207
- **Reward function (per turn t in trajectory):**
208
- ```
209
- R_total(t) = R_terminal + λ · Σ(r_s - r_{s-1} for s = t..n)
210
-
211
- R_terminal = environment score at final step n ← main signal
212
- r_s - r_{s-1} = per-step improvement delta ← shaped signal
213
- λ = 0.2 ← keeps shaped signal subordinate
214
- ```
215
-
216
- - `R_terminal` propagates backward to all turns (solves long-horizon credit assignment)
217
- - Shaped reward gives additional gradient signal at early turns without dominating
218
- - Both Developer and Critic tokens receive this advantage; Critic's shaped reward
219
- is the improvement delta from step i+1 onward (first step after its critique)
220
-
221
- **Training algorithm: full-episode GRPO**
222
- ```
223
- for each task:
224
- sample K full trajectories τ_1..τ_K (different temperatures/seeds)
225
- score each trajectory: R_terminal_k + shaped deltas
226
- compute group-relative advantage: A_t = (G_t - mean_k) / std_k
227
- update: ∇ log π(a_t | s_t) · A_t for all tokens in trajectory
228
- ```
229
-
230
- **Alternating training schedule:**
231
- ```
232
- Phase A (N episodes): Train Developer (LoRA), freeze Critic
233
- Phase B (N episodes): Train Critic (LoRA, thinking on), freeze Developer
234
- Repeat until convergence
235
- ```
236
-
237
- ### inference.py output format (unchanged from Round 1)
238
- ```
239
- [START] task=<difficulty> env=vision-coder model=<model>
240
- [STEP] step=<n> action=<html_preview> reward=<0.00> done=<true|false> error=<msg|null>
241
- [END] success=<true|false> steps=<n> score=<0.000> rewards=<r1,...>
242
- ```
243
-
244
- ---
245
-
246
- ## Git remotes
247
- Two remotes must both be kept in sync:
248
- - `origin` → GitHub (`https://github.com/amaljoe/vision-coder-openenv`)
249
- - `hf` → HuggingFace Spaces (`https://huggingface.co/spaces/amaljoe88/vision-coder-openenv`)
250
-
251
- **A `post-push` hook auto-pushes to `hf` whenever you push to `origin`.** Never push only to origin and forget HF — that's what caused multiple Phase 2 failures (HF Space was 10 commits behind for most of the day).
252
-
253
- To deploy manually use the `/deploy` skill which pushes both, waits for build, and health-checks `/reset`.
254
-
255
- ---
256
-
257
- ## Submission workflow
258
-
259
- ### 1. Push code
260
- ```bash
261
- git push origin main # post-push hook auto-syncs to hf
262
- ```
263
-
264
- ### 2. Verify HF Space is live
265
- ```bash
266
- curl -s "https://huggingface.co/api/spaces/amaljoe88/vision-coder-openenv" \
267
- | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['runtime']['stage'], d['sha'][:8])"
268
- ```
269
- Wait for `stage: RUNNING`. Docker build with CLIP takes 5–10 minutes.
270
-
271
- ### 3. Health check
272
- ```bash
273
- curl -X POST "https://amaljoe88-vision-coder-openenv.hf.space/reset?difficulty=easy" -o /dev/null -w "%{http_code}"
274
- # Must return 200
275
- ```
276
-
277
- ### 4. Submit via dashboard
278
- Only the team lead submits at: `https://www.scaler.com/school-of-technology/meta-pytorch-hackathon/dashboard`
279
- - GitHub URL: `https://github.com/amaljoe/vision-coder-openenv`
280
- - HF Space URL: `https://huggingface.co/spaces/amaljoe88/vision-coder-openenv`
281
-
282
- ### 5. Check submission status
283
- Use the `/check-hackathon-status` skill or:
284
- ```bash
285
- curl -s "https://www.scaler.com/meta-pytorch-hackathon/api/v1/submissions/status?round=1" \
286
- -H "Authorization: Bearer BAh7B0kiDHVzZXJfaWQGOgZFVGkCcp1JIg5pc3N1ZWRfYXQGOwBUbCsHLfLDaQ==--bf98a96719f439decefc69bb1a42fe0d28fa29f82fb956d8546f326225af310d" \
287
- | python3 -m json.tool
288
- ```
289
- Check `validation.agentic_evaluation.status` — target is `"success"` with all 5 steps `"pass"`.
290
-
291
- ---
292
-
293
- ## How evaluation works
294
-
295
- The evaluator does NOT use the HF Space to run inference. The flow is:
296
- 1. **HF Space ping** — pre-submission only: `POST /reset` must return 200
297
- 2. **Agentic eval**:
298
- - Clones GitHub repo to `/tmp/workspace/`
299
- - `docker build` from `Dockerfile`
300
- - Runs `inference.py` inside Docker with `HF_TOKEN`, `API_BASE_URL`, `MODEL_NAME` set
301
- - Parses `[START]`/`[STEP]`/`[END]` stdout
302
- - Validates task scores
303
-
304
- Eval pipeline: `docker_build` → `inference` → `parse_output` → `task_validation` → `llm_check`
305
-
306
- ---
307
-
308
- ## inference.py requirements (critical)
309
-
310
- Rules enforced by evaluator parser:
311
- - `action=` must be a plain string — **no `!r` repr quoting** (caused one failure)
312
- - `done` and `success` are lowercase: `true`/`false`
313
- - `error` is `null` if none
314
- - `[END]` must always be emitted — put it in a `finally` block
315
- - Exit code must be 0 — wrap everything in try/except
316
- - LLM call must be inside try/except with a fallback HTML so it never crashes
317
-
318
- Auth variable priority (evaluator sets `HF_TOKEN`, not `OPENAI_API_KEY`):
319
- ```python
320
- API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY") or "sk-placeholder"
321
- ```
322
-
323
- ---
324
-
325
- ## Challenges faced and lessons learned
326
-
327
- ### 1. HF Space drifted 10 commits behind
328
- **What happened:** `git push origin main` only updates GitHub. HF Spaces has a separate `hf` remote that needs an explicit `git push hf main`. We pushed all code fixes to GitHub but forgot to push to HF, so the Space ran month-old code for the entire evaluation window.
329
-
330
- **Fix:** Added `.git/hooks/post-push` that auto-pushes to `hf` whenever `origin` is pushed.
331
-
332
- ---
333
-
334
- ### 2. Auth failure — wrong API key env var
335
- **What happened:** Old `inference.py` read `OPENAI_API_KEY` but the evaluator only sets `HF_TOKEN`. This caused every LLM call to use `"sk-placeholder"` → 401 auth error → crash.
336
-
337
- **Fix:** Read `HF_TOKEN` first, fall back through `API_KEY` → `OPENAI_API_KEY` → placeholder.
338
-
339
- ---
340
-
341
- ### 3. Timing race — 3-minute window between commit and submission
342
- **What happened:** Commit `4dc307c` (inference.py rewrite with auth fix) was pushed at 11:49 AM. User submitted at 11:52 AM — only 3 minutes later. The evaluator cloned the repo before the push fully propagated and ran the older version without proper try/except. The submission failed despite the fix being "live".
343
-
344
- **Lesson:** After pushing a critical fix, wait at least 5–10 minutes before resubmitting to avoid the evaluator picking up stale code.
345
-
346
- ---
347
-
348
- ### 4. `!r` repr quoting on action field broke output parsing
349
- **What happened:** `log_step` used `f"action={action!r}"` which wraps the HTML string in Python quotes: `action='<!DOCTYPE...'`. The evaluator parser expected `action=<!DOCTYPE...` (no quotes). Found by comparing against the sample inference script.
350
-
351
- **Fix:** Changed `{action_summary!r}` to `{action_summary}`.
352
-
353
- ---
354
-
355
- ### 5. transformers v5 API change for CLIP
356
- **What happened:** `model.get_image_features()` in transformers v5 returns a dataclass (`BaseModelOutputWithPooling`), not a raw tensor. Code that did `features / features.norm()` crashed with `'BaseModelOutputWithPooling' object has no attribute 'norm'`.
357
-
358
- **Fix:**
359
- ```python
360
- out = model.get_image_features(pixel_values=pv)
361
- features = out.pooler_output if hasattr(out, "pooler_output") else out
362
- ```
363
-
364
- ---
365
-
366
- ### 6. Docker build downloads ~1.2GB — fragile on slow networks
367
- The Dockerfile downloads: `torch` CPU (~600MB), CLIP model weights (~600MB), Playwright Chromium (~400MB). If any download fails or times out during `docker build`, the whole eval fails at `docker_build` step.
368
-
369
- **Mitigation:** Keep these RUN layers cached by not changing requirements.txt or the download commands unnecessarily.
370
-
371
- ---
372
-
373
- ### 7. 4 Playwright browser launches per step (performance)
374
- **What happened:** `text_block_reward`, `position_reward`, `color_reward`, and `clip_visual_reward` each launched Playwright independently — 4–6 browser sessions per `step()` call.
375
-
376
- **Fix:** Render the predicted HTML once in `environment.py`, pass the `PIL.Image` as `pred_image` parameter to both `color_reward` and `clip_visual_reward` to skip duplicate renders.
377
-
378
- ---
379
-
380
- ### 9. `color_reward` false positive on white-background pages
381
- **What happened:** Original implementation sampled non-white pixels independently from each image, then compared them. A blank white prediction vs a mostly-white reference (e.g. `#f0f2f5` login form) both sampled near-white pixels → CIEDE2000 ≈ 0 → score 0.80–0.96 (false high).
382
-
383
- **Fix:** Spatial comparison — resize both images to 128×128, compute per-pixel CIEDE2000, average only over positions where the **reference** is non-white. Blank prediction at those positions gets correct high ΔE. Falls back to full mean when reference is nearly all white (< 2% non-white).
384
-
385
- ---
386
-
387
- ### 10. `structural_reward` trivially inflated for inline-style HTML
388
- **What happened:** All 15 reference HTMLs use inline `style=""` attributes, not CSS classes. The CSS class overlap term always returned 1.0 (neither ref nor pred had classes), making blank pages score 0.50 on structural instead of ~0.25.
389
-
390
- **Fix:** When ref has no CSS classes, fall back to inline style **property name** coverage: `len(pred_props ∩ ref_props) / len(ref_props)`. Blank page has 1–2 style props vs 15–25 in ref → score 0.08–0.25. Perfect match (same HTML) → same props → 1.0.
391
-
392
- ---
393
-
394
- ### 11. `validity_reward` too generous on blank pages
395
- **What happened:** `_MIN_DIVERSE_TAGS` was 5. A blank page with 4 tags (`html`, `head`, `title`, `body`) scored 4/5 = 0.80 on diversity, giving total validity ≈ 0.90.
396
-
397
- **Fix:** Raised to 8. Blank page now scores 4/8 = 0.50 on diversity → total validity ≈ 0.75.
398
-
399
- ---
400
-
401
- ### 12. Per-step GRPO loses long-horizon signal (Round 2 lesson)
402
- **What happened (design trap):** Applying GRPO independently at each step means Dev_0 only sees `r_0` — the final reward never flows back. Early turns get misguided signal regardless of episode outcome.
403
-
404
- **Fix:** Full-episode GRPO — sample K complete trajectories, apply group-relative advantage to all tokens uniformly. Augment with shaped improvement-delta reward (λ=0.2) for early-turn credit assignment. See `train.py`.
405
-
406
- ---
407
-
408
- ### 13. Critic DONE-too-early collapses GRPO variance
409
- **What happened:** The training CRITIC_TRAIN_SYSTEM said "Output DONE if closely matches" (too permissive). The Critic said DONE after step 1 on medium/hard tasks regardless of quality. With all 4 rollouts at 1 step and similar rewards, group std ≈ 0 → advantages ≈ 0 → no gradient signal.
410
-
411
- **Fix:** Stricter prompt: "Output DONE only if >90% visual similarity. If ANY section is missing/wrong, list it — do NOT output DONE." Fixes variance collapse for run 2.
412
-
413
- ---
414
-
415
- ### 14. MAX_NEW_TOKENS=1024 caused truncated HTML → artificially low training rewards
416
- **What happened:** Complex HTML pages need 1500–2500 tokens. With MAX_NEW_TOKENS=1024, the model generated truncated HTML missing closing tags and lower sections. Reward was dominated by fmt/validity only (clip=0 because truncated HTML renders blank/broken).
417
-
418
- **Fix:** Increased to 2048 in src/train.py. Also helps that 2B model's training rewards (0.23–0.35) were far below vLLM inference rewards (0.6+) — gap partly explained by truncation.
419
-
420
- ---
421
-
422
- ### 15. GRPO breakthrough emerges at ep=16 (easy difficulty)
423
- **What happened:** After 15 episodes of noisy rewards (0.23–0.35 for easy), ep=16 easy jumped to 0.496 mean reward. Individual rollouts reached 0.82 with clip=0.95 (raw cosine ~0.98). The model learned to generate HTML with high visual similarity.
424
-
425
- **Why it happened:** GRPO with variance in rollouts — when 1 of 4 rollouts achieves clip=0.90+ while others get 0.00, the group advantage strongly reinforces the high-scoring generation strategy. This is the critical moment when GRPO starts working.
426
-
427
- **Observation:** Medium and hard tasks didn't break through in run 1 due to Critic early-termination. Run 2 (with fixed CRITIC_TRAIN_SYSTEM) should show similar breakthrough for all difficulties.
428
-
429
- ---
430
-
431
- ### 16. eval_lora.py — comparing trained vs base without vLLM
432
- **Command (runs after training, outside apptainer):**
433
- ```bash
434
- export PLAYWRIGHT_BROWSERS_PATH=~/playwright-browsers
435
- /dev/shm/qwen35/bin/python eval_lora.py \
436
- --lora-path checkpoints/run2/developer_final \
437
- --model ~/models/Qwen3.5-2B \
438
- --episodes 2
439
- ```
440
- Outputs blog-ready markdown table + saves `checkpoints/eval_results.json`.
441
-
442
- **Run 1 results** (20/20 episodes, `checkpoints/run2/developer_final`, 2 ep/difficulty, temperature=0.3):
443
- | Difficulty | Base 2B | Trained 2B | Delta |
444
- |---|---|---|---|
445
- | easy | 0.924 | **0.961** | +0.037 |
446
- | medium | 0.937 | **0.955** | +0.018 |
447
- | hard | 0.919 | **0.952** | +0.034 |
448
- | **mean** | 0.927 | **0.956** | **+3.2%** |
449
-
450
- > Evaluated on bundled training samples (in-distribution). Relative delta is the meaningful signal.
451
-
452
- **Command to restart vLLM with trained LoRA** (in tmux `vllm` session, after killing old process):
453
- ```bash
454
- LORA_PATH=checkpoints/run2/developer_final LORA_NAME=qwen35-trained bash scripts/vllm.sh 2b
455
- # Then use MODEL_NAME=qwen35-trained in inference.py for trained model
456
- ```
457
-
458
- **Command to start run 2 (resume from run 1 LoRA):**
459
- ```bash
460
- apptainer exec --nv ~/apptainer-images/cuda-custom-amal_latest.sif bash -c '
461
- export LD_PRELOAD=/dev/shm/qwen35/lib/libstdc++.so.6
462
- /dev/shm/qwen35/bin/python train.py \
463
- --phase developer \
464
- --episodes 10 \
465
- --k-rollouts 4 \
466
- --model ~/models/Qwen3.5-2B \
467
- --checkpoint-dir checkpoints/run3 \
468
- --resume-from checkpoints/run2/developer_final
469
- ' 2>&1 | tee checkpoints/train_run3.log
470
- ```
471
-
472
- ---
473
-
474
- ## Self-Update Protocol
475
-
476
- **This file is a living document. Claude must keep it current.**
477
-
478
- Whenever you discover something not already recorded here — a new flag, env var, command, lesson, bug, or architectural decision — add it immediately. Do not wait to be asked.
479
-
480
- ### What to update and where
481
-
482
- | Discovery | Where to add |
483
- |---|---|
484
- | New `--flag` for vLLM / uvicorn / inference | Relevant step in "Running on rmgpu006" |
485
- | New environment variable | "Environment variables" section |
486
- | New bug or production incident | "Challenges faced and lessons learned" (next numbered entry) |
487
- | New architectural decision | Relevant subsection under "Round 2 architecture" |
488
- | Change to submission / eval behavior | "Submission workflow" or "How evaluation works" |
489
- | New debug flag or mode | "Environment variables" section with a note on what it enables |
490
-
491
- ### Rules
492
- - Write lessons in past tense under "Challenges faced" — **What happened**, **Fix** format.
493
- - Keep commands copy-pasteable and tested; update them if they change.
494
- - After updating this file, commit it: `git add CLAUDE.md && git commit -m "docs: update CLAUDE.md — <one-line summary>"`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
blog.md DELETED
@@ -1,214 +0,0 @@
1
- # VisionCoder OpenEnv | Screenshot-to-HTML with Multi-Agent RL
2
-
3
- **Scaler × Meta PyTorch Hackathon 2026 | Solo submission by [@amaljoe88](https://huggingface.co/spaces/amaljoe88/vision-coder-openenv)**
4
-
5
- Themes covered: Multi-Agent Interactions, (Super) Long-Horizon Planning & Instruction Following, Self-Improvement
6
-
7
- ---
8
-
9
- ## The Problem
10
-
11
- Turn a screenshot into working HTML. It sounds simple but it forces a model to do two hard things at once: *understand what the UI looks like visually* and *express that understanding in code*. A single LLM call tends to produce structurally valid HTML that looks nothing like the reference. Headings are present, a button is present but the layout is wrong, colors are off, nothing is positioned correctly.
12
-
13
- The deeper problem: **the model can't see its own output.** It generates HTML blindly, has no way to compare what it produced against the target, and has no feedback loop to improve.
14
-
15
- We turned this into a **reinforcement learning problem**. The agent generates HTML, a real browser renders it, a reward function computes visual similarity to the reference, and the agent iterates. The environment runs as an HTTP API compatible with the OpenEnv standard.
16
-
17
- ---
18
-
19
- ## The Environment
20
-
21
- ### OpenEnv-Compatible HTTP API
22
-
23
- ```
24
- POST /reset?difficulty=easy|medium|hard → { session_id, screenshot_b64 }
25
- POST /step { html, session_id } → { reward, render_low, render_full, done }
26
- POST /render { html } → { image_b64 }
27
- ```
28
-
29
- Every HTML submission is rendered by a headless Chromium at two resolutions: `320×240` (low-res, passed back to the Developer each turn) and `640×480` (full-res, used by the Critic and reward computation). Episodes run for up to n(=5) steps.
30
-
31
- ### Composite Reward Function
32
-
33
- The reward is a weighted sum of 8 sub-scores, each measuring a different aspect of visual and structural similarity.
34
- > Fun Fact: The weights asssigned to each reward were tuned using an auto research style approach (similar to [Andrej Karpathy's](https://github.com/karpathy/autoresearch)) - an AI agent loops through a large set of candidate weight combinations parallely and compares the reward ranking against human quality judgements to find the best correlation.
35
-
36
- ![Reward weights](https://raw.githubusercontent.com/amaljoe/vision-coder-openenv/main/assets/reward_weights.png)
37
-
38
- | Reward | Weight | What it measures |
39
- |---|---|---|
40
- | `format` | 0.5 | Has ` ```html ` fence + `<!DOCTYPE html>` |
41
- | `validity` | 0.5 | Structural completeness (html/head/body, diverse tags) |
42
- | `structural` | 0.5 | Tag-sequence similarity + inline-style property coverage |
43
- | `text_block` | **3.0** | Hungarian-matched text block IoU + text similarity |
44
- | `position` | 1.0 | Hungarian-matched centroid distance |
45
- | `color` | 1.5 | Spatial CIEDE2000 on reference non-white pixels |
46
- | `clip` | **2.5** | CLIP ViT-B/32 cosine similarity, renormalised (threshold 0.65) |
47
- | `ssim` | 1.5 | Pixel-level SSIM (skimage, 320×240 RGB) |
48
-
49
- Low-weight rewards (`format`, `validity`, `structural`) saturate early, a structurally complete page already scores near 1.0 on these regardless of visual quality. The high-weight rewards (`text_block`, `clip`, `ssim`) stay discriminative all the way to near-perfect renders. This keeps the gradient signal alive even when the model is already producing good output.
50
-
51
- ### Does the Reward Reflect Human Judgement?
52
-
53
- We validated the final reward function against human-labelled quality levels across 15 reference pages (5 per difficulty). For each reference, we tested 7 variants ranging from blank to perfect:
54
-
55
- ![Reward discrimination](https://raw.githubusercontent.com/amaljoe/vision-coder-openenv/main/assets/reward_discrimination.png)
56
-
57
- **Global Spearman ρ = 0.955** — the reward ranking matches human quality judgement on most of the test cases. The chart above shows the reward correctly ordering all 7 levels with clear gaps between them.
58
-
59
- Browse all 15 test case renders with per-sub-reward breakdowns in the **[interactive demo](https://amaljoe.github.io/vision-coder-openenv/)**.
60
-
61
- The grid below shows sampled renders from three tasks alongside their reward scores. Each row shows a reference and three variants at different quality levels, ordered from best to worst:
62
-
63
- ![Reward grid](https://raw.githubusercontent.com/amaljoe/vision-coder-openenv/main/assets/reward_grid.png)
64
-
65
- > **Content Multiplier:** We noticed strong correlation with human judgement for most pages, but blank renders were receiving rewards of ~0.3 due to sub-rewards like `format` and `validity` that don't require visual content. To fix this, we applied a content multiplier: if the predicted render has fewer than 0.5% non-white pixels while the reference has content, the total reward is forced to 0. A blank page which typically means something prevented rendering (a JavaScript error, a malformed tag, or the model failing to generate HTML at all) now gets the worst possible reward and is correctly treated as a major failure signal.
66
-
67
- ---
68
-
69
- ## The Multi-Agent Architecture
70
-
71
- ### Why Two Agents?
72
-
73
- A single agent can generate HTML and receive a reward. But the reward is a single number: it tells the model *how bad* the output is, not *what is wrong* or *which selector to fix*. Without visual feedback, the model improvises changes at random and often regresses.
74
-
75
- The Critic solves this. It looks at both the reference and the current render side by side, reads the HTML source, and produces specific CSS fix instructions. The Developer reads those fixes and applies them in the next step; no guessing required.
76
-
77
- ![Dual-agent architecture](https://raw.githubusercontent.com/amaljoe/vision-coder-openenv/main/assets/dual-agent-architecture.png)
78
-
79
- ### Why Not Just Pass Everything to One Model?
80
-
81
- Context cost. Vision models encode images as sequences of tokens; the number of tokens scales with pixel count:
82
-
83
- | Image | Resolution | Visual tokens |
84
- |---|---|---|
85
- | Low-res render | 320×240 | ~256 |
86
- | Full-res render / reference | 640×480 | ~1,024 |
87
- | Full HD (hypothetical) | 1920×1080 | ~9,800 |
88
-
89
- With full-HD inputs, two images alone would cost ~19,600 tokens exhausting the context budget of a typical consumer GPU before a single token of HTML is generated. Even at our working resolution, giving the Developer both high-res images every step would double its context cost per step across the entire episode and this cost increases quadratically with higher resolutions.
90
-
91
- ### What the Critic Produces
92
-
93
- ```
94
- [+] HIGH | LAYOUT — products grid is 1-column; reference shows 3-column
95
- → FIX: `.products { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }`
96
-
97
- [+] MEDIUM | COLOR — nav background is white; reference shows dark navy
98
- → FIX: `nav { background-color: #0f172a; }`
99
- ```
100
-
101
- This is fundamentally different from abstract feedback ("the layout is wrong"). The Developer reads the `→ FIX:` line and applies it to the exact CSS selector, no interpretation required.
102
-
103
- ### Self-Improvement Over an Episode
104
-
105
- Each developer step sees the HTML code generated so far alongside reviews from the critic model and its low-resolution renders (to maintain a manageable context size).
106
-
107
- The graph below shows what happens with and without the Critic over a 5-step episode:
108
-
109
- ![Episode reward progression](https://raw.githubusercontent.com/amaljoe/vision-coder-openenv/main/assets/episode_progression.png)
110
-
111
- Without structured feedback, the Developer oscillates: it makes changes that sometimes improve and sometimes regress the reward. With the Critic providing selector-specific fixes, the reward climbs monotonically. By step 5, Developer + Critic has opened a **Δ0.18 gap** over Developer Only.
112
-
113
- ---
114
-
115
- ## RL Training: Full-Episode GRPO
116
-
117
- ### Full-Episode Training
118
-
119
- Full-episode GRPO samples K complete trajectories, scores each one by total episode reward, and applies group-relative advantage to every token in the trajectory. Reward shaping is also used to add additional intermediate rewards (difference in rewards between each iteration):
120
-
121
- ```
122
- R_total(t) = R_terminal + λ · Σ(r_s - r_{s-1} for s = t..n)
123
-
124
- R_terminal = environment score at final step n ← main signal
125
- r_s - r_{s-1} = per-step improvement delta ← shaped signal
126
- λ = 0.2 ← keeps shaped signal subordinate
127
- ```
128
-
129
- ```
130
- for each task:
131
- sample K=4 full trajectories (different temperatures/seeds)
132
- score each: R_terminal_k + shaped improvement deltas
133
- advantage: A_t = (G_t - mean_k) / std_k
134
- update: ∇ log π(a_t | s_t) · A_t for all tokens in trajectory
135
- ```
136
-
137
- ### Training Configuration
138
-
139
- - **Base model**: [`Qwen/Qwen3.5-2B`](https://huggingface.co/Qwen/Qwen3.5-2B) (unified vision+text)
140
- - **LoRA**: rank=16, α=32, 0.49% trainable parameters (10.9M / 2.2B)
141
- - **Optimizer**: AdamW, lr=2e-5, max_grad_norm=1.0
142
- - **Hardware**: 2× NVIDIA A100 80GB PCIe
143
- - **Episodes**: 20 × 4 rollouts = 80 trajectories
144
-
145
- ### Training Curve
146
-
147
- ![Training curve](https://raw.githubusercontent.com/amaljoe/vision-coder-openenv/main/assets/training_curve.png)
148
-
149
- The three difficulty tracks tell different stories:
150
-
151
- **Easy (blue)** starts at 0.629. Simple login forms and single-column layouts are already within reach of the base model. There is very little headroom left, so the curve shows mostly small fluctuations with a slight upward drift. The model is already close to its ceiling on these tasks at baseline.
152
-
153
- **Medium (green)** starts at 0.488 and ends at 0.634 (+0.146). Multi-column grids and landing pages require the Critic's feedback to land correctly. The reward climbs early as the model learns to apply CSS fixes more precisely.
154
-
155
- **Hard (red)** shows the clearest improvement: 0.346 → 0.564 (+0.218). Complex dashboards and Kanban boards depend on deeply nested flex/grid structures where small CSS errors collapse entire layout regions. At baseline, the model struggles to reconstruct these. With GRPO reinforcing the Critic's CSS fix patterns, it learns which selectors control which regions and how to fix them efficiently. The performance keeps on climbing even at 20 iterations and shows potential for more improvement. **Hard tasks benefit the most because they have the most to gain.**
156
-
157
- ---
158
-
159
- ## RL Training Results: Base vs Trained 2B
160
-
161
- Scores at iteration 0 (untrained) vs iteration 20 (after GRPO training), from `https://raw.githubusercontent.com/amaljoe/vision-coder-openenv/main/assets/train.jsonl`:
162
-
163
- | Difficulty | Base (iter 0) | Trained (iter 20) | Delta |
164
- |---|---|---|---|
165
- | easy | 0.629 | **0.634** | +0.005 |
166
- | medium | 0.488 | **0.634** | +0.146 |
167
- | hard | 0.346 | **0.564** | +0.218 |
168
- | **mean** | 0.488 | **0.611** | +0.123 |
169
-
170
- **+25.2% overall improvement** from 20 iterations of full-episode GRPO on 2× A100 80GB (~2h). The pattern matches the training curve: easy was already near its ceiling, medium gained meaningfully, and hard improved the most. The Critic's structured feedback is most valuable precisely where the task is most complex.
171
-
172
- ---
173
-
174
- ## Reproduce
175
-
176
- ### Run the Environment
177
-
178
- ```bash
179
- pip install -e .
180
- uvicorn openenv.server.app:app --host 0.0.0.0 --port 7860
181
- ```
182
-
183
- ### Run Inference
184
-
185
- ```bash
186
- export API_BASE_URL=https://router.huggingface.co/v1
187
- export MODEL_NAME=Qwen/Qwen3.5-35B-A3B
188
- export HF_TOKEN=hf_...
189
- python inference.py
190
- ```
191
-
192
- ### Run RL Training
193
-
194
- ```bash
195
- python train.py --phase combined --episodes 20 --k-rollouts 4 \
196
- --model Qwen/Qwen3.5-2B --checkpoint-dir checkpoints/run1
197
- ```
198
-
199
- ### Run Test Suite
200
-
201
- Run the test suite to generate rewards for the test set. These rewards can be visualised in the [interactive demo](https://amaljoe.github.io/vision-coder-openenv/).
202
-
203
- ```bash
204
- python tests/test_rewards.py --render # first run (needs Playwright)
205
- python tests/test_rewards.py # subsequent runs (uses cached renders)
206
- ```
207
-
208
- ---
209
-
210
- ## Links
211
-
212
- - **HF Space**: [amaljoe88/vision-coder-openenv](https://huggingface.co/spaces/amaljoe88/vision-coder-openenv)
213
- - **GitHub**: [amaljoe/vision-coder-openenv](https://github.com/amaljoe/vision-coder-openenv)
214
- - **Interactive demo**: [amaljoe.github.io/vision-coder-openenv](https://amaljoe.github.io/vision-coder-openenv/)