Local MLX, 48 GB. Jun 20–21 2026. Goal: RL-with-verifiable-rewards on Jac
body-completion — Jac compiler/runtime grades each generation (compile + run +
output-match + idiom + body-similarity); optimize the model against it. No learned
reward model. Method: GRPO (LoRA, mlx-lm-lora). Full detail and the redesigned workflow live in 02-rl-grpo/docs/rl/00-overview.md and 02-rl-grpo/docs/rl/01-design.md.
Pass = exact stdout match after splicing model completion into task template.
Supervised levers (SFT, DPO, gold warm-start) move models here. RL does not. GRPO added nothing (confirmed by pass@8), STaR added a faint flicker that didn't hold, and the bigger Qwen3.6 models can't be trained on 48 GB. Harness/reward/eval all correct and proven on real 30B — the science result is that LoRA-RL on a 30B-class model doesn't beat SFT at this task difficulty / scale / hardware.
Phase 1 — 31 tasks, holdout 7 (pass %, exact stdout):
| model | base | +warm | +grpo |
|---|---|---|---|
| jac-qwen3coder (MoE 30B-A3B, SFT+DPO) | 14.3% | near-ceiling | 14.3% |
| qwen3coder (MoE 30B-A3B, fresh) | 0% | 14.3% | 14.3% |
| qwen36 (dense 27B) | 0% | OOM | OOM |
Phase 2.5 — 51 tasks, holdout 12. pass@8 re-measure of existing adapters: pass@1 == pass@8 == 14.3% for all of {jac base, jac grpo, q3c warm, q3c grpo}.
STaR (iterate warm-start; pass@1 greedy / pass@4 sampled):
| model | round 0 | round 1 | round 2 |
|---|---|---|---|
| qwen3coder | 16.7% / 16.7% | 16.7% / 25.0% | 16.7% / 16.7% |
| jac-qwen3coder | 16.7% / 16.7% | 16.7% / 16.7% | 16.7% / 16.7% |
| qwen36 (35B-A3B) | base 8.3% — untrainable | — | — |
All in 02-rl-grpo/rl/reward_logic.jac (Jac; 02-rl-grpo/rl/reward.py is a 2-line shim the
mlx-lm-lora loader needs). Every variant is a verifiable reward — splice
completion into the task template at __HOLE__, jac run it in an isolated cwd,
score the result. No learned reward model. Evolution, in order:
0.3·compiles + 0.3·runs + 0.3·output_match + 0.1·idiom. output_match and idiom
both binary (exact stdout / has-idiom-markers).
- Why: the obvious RLVR reward — compiler + runtime grade for free.
- Didn't work: at ~0% base pass, every rollout in a group scored the same low
value → group σ = 0 → GRPO advantage
(r−mean)/σ = 0→ zero gradient at any LR (the zero-advantage trap, failure #2). Binary all-or-nothing gives no within-group variance to learn from when nothing passes.
Made two terms continuous instead of binary:
- Soft output (
output_score): exact = 1.0, else0.5 · difflib ratio(out, expected)— behaviorally-close stdout earns partial credit without rivaling exact. - Weighted idiom (
idiom_bonus): graph/object-spatial ops weighted heavier (visit/-->/spawn/disengage= 3,report/here/walker/node/edge= 2, plaincan/has/obj= 1), normalizedmin(n/8, 1). Rewards genuinely graph-spatial bodies over function-shaped ones. Non-running code earns no idiom credit. - Group dedup cache: identical rollouts (common early) scored once, not re-run — cuts subprocess cost, not a reward-quality change.
- Partial fix: added variance when code runs, but at ~0% pass almost nothing
ran → output and idiom both gated behind
runs, so still mostly σ=0. Not enough.
0.25·compiles + 0.25·runs + 0.25·output + 0.10·idiom + 0.15·sim, where sim =
difflib ratio(body, gold refbody) is computed for EVERY completion — even
non-compiling ones (the only term not gated behind runs).
- Why: sim is always defined, so even a group where nothing compiles gets
within-group reward variance → nonzero advantage → a learnable gradient at cold
start. Verifiable terms still dominate (0.75 of weight); sim is the tiebreaker.
Needs sidecar gold bodies in
02-rl-grpo/dataset/rl/refbodies/(51 of them). - Worked, partially: broke the σ=0 trap — σ rose to 0.01–0.15, training loss became nonzero (0.02–0.05), real gradient flowed. This is what the reward was supposed to do, and it did it.
- Didn't translate to results: even with a real gradient, +grpo greedy eval stayed byte-identical (KL≈0) — see failure #4. The reward was no longer the bottleneck; LoRA-GRPO's effect on a 30B's argmax was. A correct, well-shaped reward is necessary but not sufficient.
For eval (eval_rl.jac), not GRPO loss: added near-pass (osim ≥ 0.9) and
avg-osim (continuous output similarity over all tasks) alongside exact pass, so
partial progress is visible where exact-match stays 0→0. Diagnostic only — exposed
that misses are tiny Jac slips (Missing ';', here.jid vs jid(here)), not
wholesale wrong code.
The reward engineering succeeded at its own job — v2 produces real, non-zero, well-shaped gradient signal on real 30B rollouts, and the soft/dense terms killed the σ=0 trap that faked Attempt 1. The reward is not why RL underperformed. The splice bug (failure #3) faked the early flat runs, and once fixed, the wall became LoRA-GRPO being too weak to move greedy decoding (failure #4) — a model/training limit, not a reward-design limit. A reward can only amplify a success signal the base produces; at ~8–17% exact-pass on hard tasks there's little to amplify.
-
OOM at iter 2 (Metal). First GRPO config group6/comp512 blew 48 GB. Fix: defaults → group4/comp256/seq1280 (~38 GB peak). Not a real blocker.
-
σ=0 zero-advantage trap (Attempt 1, flat). At ~0% base pass, every rollout in a group scored the same low value → GRPO advantage
(r−mean)/σ = 0→ zero gradient regardless of LR. KL = 0.0 every step. RL cannot bootstrap a skill the base has 0% of. Fix attempt: dense body-similarity reward term → σ rose, loss nonzero — gradient flowed but greedy eval still identical (suspicious → led to #3). -
THE real bug — broken splice (faked Attempts 1–2 entirely). Models emit the WHOLE unit
can walk_day with Day entry { <body> }, but the template hole sat inside the unitcan ... { __HOLE__ }. Splicing full unit into inner hole → nested broken Jaccan {...can {...}...}→ never ran. So base, warm (SFT loss 0.006!), and all GRPO adapters scored identically because the splice — not the model — was broken. Every "RL is flat" conclusion before this measured nested garbage. Fix:unwrap_unitinextract_jac(reward_logic.jac + eval_rl.jac) — unwrap a single enclosing unit to its inner block before splicing. After fix, runs == pass. -
LoRA-GRPO doesn't move greedy output (Attempts 3–4, the core null result). With splice fixed and real reward variance (σ up to 0.11, loss 0.02–0.05), +grpo eval was still byte-identical to base/warm, KL ≈ 0, on every model. At feasible LR (≤1e-5) / 300 iters, LoRA-GRPO barely perturbs a 30B's argmax decoding. pass@8 re-measure confirmed pass@1 == pass@8 → the null is real, not a greedy blind spot. GRPO is a dead end here.
-
Dense Qwen3.6-27B untrainable on 48 GB. Warm-start SFT OOM'd at iter 1 across every config (16/8/4 layers, batch 1, seq 2048→768, grad-checkpoint). A dense model activates all 27B params/token → activation memory exceeds 48 GB regardless of LoRA rank. Inference fits, training doesn't. Base-eval only.
-
35B-A3B swap didn't fix training either. Swapped dense 27B → Qwen3.6-35B-A3B (MoE) expecting it to train like the 30B-A3B. It still OOMs SFT at iter 1: the 256-expert MoE keeps all experts resident (~18 GB q4) and backward through them exceeds 48 GB. Same wall as the dense, different reason (expert count vs all-active). Whole Qwen3.6 line is inference-only on this box; only the fewer-expert 30B-A3B (Qwen3-Coder) trains.
-
STaR flicker didn't hold. Loop works (adds 2–4 exact-correct samples/round). qwen3coder round 1 flickered to pass@4 = 25% (3/12) — faint, real — but didn't persist and greedy (pass@1) never left the ~16.7% SFT floor.
- Warm-start (gold-SFT) where the base has room: fresh qwen3coder holdout 0% → 14.3%. The one measured RL-adjacent win. The jac-trained base was already near-gold so warm-start barely moved it.
- Harness, reward, pass@k eval, warm-start, STaR loop, 51-task set, 35B-A3B swap — all built and validated on real 30B (no mocks).
- Coarse metric: exact full-stdout match at hard tasks gives an ~8–17% floor
and almost no positive gradient. Misses are tiny Jac slips (
Missing ';',here.jidvsjid(here)) that all-or-nothing scoring rejects. - LoRA + feasible LR too weak to shift a 30B's greedy decoding via RL.
- Hardware: 48 GB trains only the 30B-A3B (MoE, 3B active, ~38 GB peak); every dense/large-MoE Qwen3.6 OOMs.
- Partial/line-level grading so the base scores >0% broadly → real RL gradient.
- Full-finetune RL (not LoRA) and/or >48 GB VRAM.
- 100s of tasks for generalization beyond a 12-task holdout.