# align roadmap: robust training, OLMoE-class MoE, formal-proof track Companion to `docs/REFERENCE_GAP_ANALYSIS.md` (evidence and citations live there; item IDs below map to its sections). Paths are relative to `/apps/development/rlhf/align/`. Effort: S <=2 days, M ~1 week, L 2-4 weeks, XL >1 month. ## Ground rules for every item **Dependency policy** (`docs/DEPENDENCY_POLICY.md`, enforced by `tests/test_dependency_firewall.py`): runtime imports are torch, triton, numpy, safetensors and stdlib only. Reference code is ported by reading it, never imported. Oracles against HF/transformers/mistral-common live behind the `oracle` marker in the legacy venv. Anything needing a banned package (`pyarrow` is the single whitelisted file, `data/ingest_parquet.py`) goes in `extras/`. Every item below states "compliant" only if it needs nothing outside that set; kernels additionally pass through `kernels/gate.py`. **Host-safety preconditions** (`/apps/CLAUDE.md`, enforced in `utils/guard.py`) apply to every step marked GPU: 1. `free -g; uptime; awk '$3=="nvme2n1"{print $12}' /proc/diskstats` passes: MemAvailable >= 45 GB, 1-min load < 2x cores, disk queue < 64. `ps -eo stat | grep -c '^D'` not sustained above ~5. 2. `nvidia-smi` shows the needed GPUs free (they carried 13-19 GB of other jobs when the maps were written). Single-GPU validation pins one device via `CUDA_VISIBLE_DEVICES` before importing torch. 3. One heavy job at a time (heavy-job flock, `acquire_heavy_job_lock`). Never concurrent with a data build. 4. No writes to `/` or `/tmp`; scratch in `.tmp/`; at most 2 checkpoints kept; `--num-workers` <= 2 per rank; save every <= 100 micro-steps for anything longer than 20 minutes; delete partials immediately. 5. Verify progress by `stat -c %y` on the log, not PIDs. Items marked **GPU** need those; everything else is CPU-testable inside the existing CPU test loop (gloo for multi-rank). **Definition of done for any item**: new tests green, `pytest tests/ -m "not gpu"` count does not regress, firewall test green, README/ARCHITECTURE updated where behaviour changes, and a recorded deviation note if a documented contract changes. --- ## Status at 654c20c (2026-10-12) Measured on the released OLMoE-1B-7B-0125 weights and on 4x A10G. Sources: `docs/MEASUREMENTS.md` (Track M sections (a) to (h)) and `docs/LEAN_VERIFIER.md`. Each line names its section. - **OLMoE real-checkpoint parity (P1 MoE core, P5 rows):** fp32 max abs logit diff 1.4e-5 to 3.0e-5 against transformers 5.15, top-1 74/74, router top-8 sets 100% on all 16 layers (MEASUREMENTS (e)). The gate is fp32 < 1e-3. bf16 (1.31) is a noise floor, not a gate. - **Deterministic MoE dispatch (P1-3):** the bf16 `index_add_` combine gave 5 of 5 different top-8 expert sets on identical inputs. The fixed-order combine gives 1 of 1 (MEASUREMENTS (f)). - **4-GPU FSDP2 SFT kill/resume (P0-1, P0-8, P0-9):** a SIGTERM to one of four ranks stops all four at the same optimizer boundary. The supervisor relaunch resumes at micro 44 and loss stays within the run-to-run floor (max 3.3e-3 against a tolerance of 1.09e-2; MEASUREMENTS (g)). On 2 ranks, resume from a stop checkpoint and from a cadence checkpoint is bit-exact against an uninterrupted run, and a wrong optimizer shard is detected (MEASUREMENTS (a)). - **FSDP cost, replacing the old constants (P1-15):** 277 padded tokens/s at 4 x 1,024-token micro-batches is **0.53 TFLOP/s per GPU**. FSDP is comm-bound over PCIe (13.55 of 14.8 s per micro-step). A micro-batch of 8,192 tokens gives 2,140 tokens/s. The 4-GPU ceiling for this box is about 2 to 3.4 k tokens/s, which is **0.2 to 0.3 B tokens/day**. This replaces the earlier 25 TFLOP/s assumption and the 1.1 B tokens/day figure (MEASUREMENTS (h)). - **Dense sampler rank sharding (fixed in 654c20c):** `scripts/sft.py` and `scripts/pretrain.py` gave every rank the same batch stream. `build_training_sampler` now shards through `DistributedBatchSampler` (`tests/test_training_sampler_sharding.py`). No dense 7B run has been made since the fix. - **Lean verifier, Track L (commit label "P1"; implements the P4-1 row):** pre-registered ceiling test accepts 177 of 178 known-good proofs (1 timeout) and 0 of 61 adversarial cases (`docs/LEAN_VERIFIER.md`). **Open:** no OS sandbox. The REPL runs as the same user with filesystem access. Run the pool as an unprivileged user before adversarial RL. - **Still open before a multi-hour GPU run:** P0-3 (`offload_adamw` clipping), P0-5 (`no_sync`), P0-6 (rank-local logging; the `history.jsonl` collision is live), P0-7 (token-count normalisation), P0-14 (adamw8bit at 1.8B local elements), P1-1 (`ModelConfig.moe`), and `kill -9` during a save. ## P0: robustness blockers (do first; each is small) Goal: a multi-hour 4-GPU SFT/DPO/GRPO or continued-pretrain run that cannot silently burn GPU time, loses little on a kill, and whose logs can be trusted. | ID | Item | Acceptance test | Files | Effort | GPU? | |---|---|---|---|---|---| | P0-1 | Coordinated cancel: SIGTERM/SIGINT handler sets a flag; flag all-reduced (MAX) on the gloo group every K micro-steps; loop exits at an optimizer-step boundary, writes a final synchronous checkpoint, restores handlers. Add `time_limit_s` and `extra_steps_after_cancel` | Multi-process gloo test: send SIGTERM to one rank mid-run, all ranks stop at the same step, checkpoint loads and resumes to bit-identical state; no hang | edit `train/trainer.py`, `utils/dist.py`; new `tests/test_trainer_cancel.py` | S | no | | P0-2 | Non-finite protection: halt when consecutive skipped optimizer steps exceed N or the all-reduced logged loss is non-finite; make `GradNormTripwire` treat NaN as a fire, not "blind"; decision from a reduced value on every rank | Injected NaN-grad test: run halts after N skips with a clear error on all ranks, no NCCL-timeout hang; PPO `max_consecutive_skips` behaviour unchanged | edit `train/trainer.py`, `train/probes.py:325`, `config.py`; extend `tests/test_trainer*.py` | S | no | | P0-3 | Clipping on for every optimizer: move global-norm clip into `TrainModule.optim_step` (covers `offload_adamw`), default 1.0 in sft/dpo/pretrain TOMLs, log pre-clip norm | Test that offload_adamw and adamw8bit clip identically on a synthetic grad; pre-clip norm in `history.jsonl`; stale comment `offload_adamw.py:632` removed | edit `train/train_module.py`, `train/offload_adamw.py`, `configs/{sft,dpo_*,pretrain}.toml` | S | no | | P0-4 | Arm tripwires by default for SFT/pretrain (`grad_norm`, `length_drift` as applicable) and add the spike-skip gate (rolling 128-step mean + 6 sigma, skip not halt, spike-rate metric) | Config test: sft/pretrain TOML load with armed tripwires; spike injection skips one step and logs it | edit `config.py:329-335`, `train/probes.py`, optimizers' pre-update gate | S-M | no | | P0-5 | FSDP `no_sync` / `set_requires_gradient_sync(False)` on non-final micro-batches | CPU gloo test: gradient after N micro-batches equals the sync-every-step result (fp32 tolerance) and reduce-scatter call count drops to 1 per optimizer step | edit `train/train_module.py`, `train/fsdp.py` | S | **GPU** to measure step-time gain (single 4-rank 20-step probe via the existing probe gate) | | P0-6 | Verify, then fix, rank-local logging: reduce loss/token counts on the gloo group at log cadence, rank-0-only `Tracker` writes, tripwire decisions from reduced metrics | First a 2-rank gloo test that reproduces (or refutes) interleaved `history.jsonl` records; after the fix, exactly one record per log step with the dp-mean loss | edit `train/trainer.py:315-317,505-589`, `utils/tracking.py` | M | no | | P0-7 | Token-count loss normalisation: TrainModule computes valid-token count of the whole rank batch first, passes `loss_div_factor` to the loss adapter, sum-reduction per micro-batch, FSDP divide factor handled | Test: split one batch into 1, 2, 4 micro-batches and across 1/2 ranks, loss and grads equal within fp32 tolerance (today they differ when valid tokens vary). Interface change documented because MoE aux losses depend on it | edit `train/train_module.py`, `train/sft_loss.py`, `train/dpo_loss.py`, `rl/grpo_module.py` (check contract) | M | no | | P0-8 | Checkpoint hardening: fsync files and parent dir before the publishing rename; `latest_checkpoint` validates completeness (resume.pt + safetensors + manifest) and prefers highest step across the run's own dirs; persist `pinned_checkpoints` in `resume.pt`; marker-first delete in prune; sparse permanent tier (`permanent_every_micro_steps`, weights-only) | Kill-during-save and kill-during-prune tests leave a loadable newest checkpoint; stale higher-numbered foreign dir does not shadow; pins survive resume | edit `train/trainer.py:626-886`, `config.py` | S | no | | P0-9 | Save RNG state (torch cpu/cuda, numpy, python) and split `load_optim_state` / `load_trainer_state` flags in resume | Resume reproduces dropout/sampling stream exactly on one rank; warm-start-without-optimizer path covered | edit `train/trainer.py:888-1019` | S | no | | P0-10 | Distributed init: `cpu:gloo,cuda:nccl` composite backend, configurable timeout, `set_device` before init; GC control (disable auto GC, gen-1 collect every 1000 steps and after saves) | gloo test unchanged; unit test that GC is restored on exit; timeout reads from config | edit `utils/dist.py:32-41`, `train/trainer.py` | S | no (NCCL timeout behaviour only matters on GPU) | | P0-11 | Launch supervisor `scripts/supervise.sh`/`.py`: relaunch torchrun after crash only if the host preflight passes AND `history.jsonl` mtime shows progress since the last attempt; bounded retries with backoff; GPU-idle and temperature check via nvidia-smi; reuses the heavy-job flock | Fake-crash script test: retries stop after the bound, never launches a second concurrent job, refuses on a failing preflight | new `scripts/supervise.py`, edit `utils/guard.py` | S | no (dry-run test) | | P0-12 | Triton FA2 gate decision: either re-run `scripts/gate_flash_attention.py` on one free GPU so the gate lands in `.cache/kernel_gates`, or document SDPA as the only supported backend and drop the dead artifact path | `select_attn_implementation` result matches the documented state on a clean checkout | edit `kernels/gate.py` doc, `model/attention.py`; README | S | **GPU** (one A10G, minutes) | | P0-13 | Sharded sync checkpoint (no rank-0 gather): each rank writes its own weight shard and its own optimizer shard with the OLMo write discipline (tmp + fdatasync + replace), a manifest records world size, mesh and per-tensor placements; keep HF-safetensors export as a separate streaming per-tensor step. Optimizer state for adamw8bit/offload written dequantise-on-save or with a documented per-rank layout plus a reshard tool | Round trip at world size 2 on CPU; load at world size 1 and 4 for weights via the reshard path; host-RAM peak at save is bounded by one shard (assert with the existing allocation-ledger style test); `roundtrip_gate` still passes | new `train/dcp_ckpt.py`, edit `train/trainer.py`, `train/train_module.py`, `model/checkpoint.py` | L | **GPU** for a 7B 4-rank save/load timing run under the preflight; start with the tiny config on CPU | | P0-14 | GPU evidence pass for adamw8bit: single-GPU step time and parity at 1.8B local elements, then 4-rank 20-step run with the stage gates | Numbers recorded in README status; no regression vs CPU-verified claims | scripts only (`bench_7b_end_to_end.py`) | S | **GPU, full preflight, exclusive** | Exit criterion for P0: kill -TERM, kill -9 at a random point, injected NaN, and injected loss spike each end in the documented state in automated tests; one real 4-GPU run of at least 2 hours survives a deliberate SIGTERM and resumes with matching loss. ### G0 status (2026-10-12): what landed, in commit-ready form Gate G0 is the set of control-plane fixes that must hold before any multi-hour GPU run. Everything below is verified on CPU (single process, plus 2-process gloo for the collective halves); **nothing here has run on NCCL, four ranks or a 7B model.** Items are listed by roadmap ID. "Done" means the acceptance test in the table exists, passes, and has a mutant that removes the fix and fails it. | ID | Status | What landed (commit message) | Not done | |---|---|---|---| | P0-1 | **done** | `trainer: coordinated stop` - SIGTERM/SIGINT set a flag; `train.max_wall_seconds` is a second source; both are MAX-reduced with the skip/non-finite flags in ONE collective at every optimizer-step boundary, never mid-window. A stop always writes a checkpoint, wins over `max_micro_steps`, restores handlers, and a second SIGINT escapes. Stage scripts exit `Trainer.stop_exit_code` (82 wall budget, 128+n signal) instead of running the post-train eval of a half-trained model. 2-rank gloo tests: SIGTERM to rank 1 only, SIGTERM from a parent, wall clock on one rank. | `extra_steps_after_cancel`; the flag is reduced every boundary, not every K micro-steps (cheaper than the plan, no downside seen). | | P0-2 | **done** | `trainer: skip cap + non-finite loss + rank-reduced HALT` - a "bad" step is one the grad gate skipped or one with any non-finite micro-batch loss (a loss-only NaN with finite grads used to train on). Halts (`NonFiniteHaltError`, exit 81) when consecutive bad steps exceed `max_consecutive_skipped_steps` (3) or the bad fraction exceeds `max_skipped_fraction` (5%, evaluated after 100 boundaries). Tripwire HALTs and the arm-time self-test are reduced first, so every rank raises the same `HaltError` (exit 80) instead of one rank leaving its peers in a collective. Counters persist in `resume.pt`. PPO's critic-only warmup (a step that returns False by design) does not count. | `GradNormTripwire` still treats a NaN norm as "blind" (the skip cap covers it); GPU/NCCL no-hang check. | | P0-3 | partial | `config: max_grad_norm defaults to 1.0` (OLMoE / OLMo 2 value; `adamw8bit` clips with the global norm and logs the PRE-clip norm, so the grad-norm tripwire is not blinded); stated explicitly in sft/pretrain/dpo TOMLs and `DpoStageConfig`. | `offload_adamw` still does not clip (the default is a no-op there); the clip is not yet in `TrainModule.optim_step`; stale comment at `offload_adamw.py:632`. | | P0-4 | partial | `configs: arm grad_norm tripwire for sft and pretrain` (the only tripwire those stages can feed; `logps_chosen`/`length_drift` need preference pairs and stay off - arming a guard you cannot feed is how a run halts at step 1 or runs blind). | The rolling-mean + 6 sigma spike-skip gate and its spike-rate metric. | | P0-8 | partial | `checkpoint: durable publish + completeness` - fsync every file then every directory, rename, fsync the parent; `latest_checkpoint` skips (and warns about) a directory whose weights header, `resume.pt` loop state, or any per-rank optimizer file is missing/truncated, while keeping it on disk; `pinned_checkpoints` persist in `resume.pt` and survive resume + prune. | Marker-first delete in prune; the sparse permanent tier; highest-step-across-dirs rule. | | P0-9 | partial | `checkpoint: persist RNG` - python, numpy, torch CPU and the current CUDA device are gathered per rank at save and restored on resume (skipped with a warning at a different world size). | Separate `load_optim_state` / `load_trainer_state` flags. CUDA RNG path tested with a fake only. | | P0-10 | partial | `dist: explicit process-group timeout + NCCL flight recorder` - `ALIGN_PG_TIMEOUT_S` (default 1800 s, parsed strictly), `TORCH_NCCL_TRACE_BUFFER_SIZE`, `TORCH_NCCL_DUMP_ON_TIMEOUT` and the dump-prefix variables (both generations of the name) set before group creation with the dump under `/nccl_trace`, never `/tmp`. Every stage script passes `run_dir`. | `cpu:gloo,cuda:nccl` composite backend; GC control; `TORCH_NCCL_ENABLE_MONITORING` is not set (the header says DUMP_ON_TIMEOUT needs it; its 2.11 default is unverified - needs a 2-rank NCCL run). | | P0-11 | partial | `supervise: bounded-retry launch supervisor` (`scripts/supervise.py`) - relaunch only if exit != 0, not a HALT code, no fresh `HALT.json`, retries < 2, `history.jsonl` mtime AND the newest *complete* checkpoint advanced, and the host preflight passes after the backoff; child in its own process group, killed and confirmed empty before a relaunch. `align.exit_codes` defines 80/81/82/83 and the halt marker: **measured, `torchrun` exits 1 whatever its workers exit with**, so the marker file, not the exit code, is what reaches the supervisor under torchrun. Guard refusals are `PreflightRefused` (83). | nvidia-smi GPU-idle/temperature check; a crash before the first checkpoint of a fresh run stops the supervisor (deliberately strict). | | per-rank | done | `dpo, grpo: plumb train.per_rank_optimizer_state` into `DpoStageConfig` / GRPO `TrainCfg` and the scripts' `_run_config`; ON in `dpo_argilla.toml`, `dpo_steppairs.toml`, `grpo_stageb.toml` (sft/pretrain already); GRPO `[train]` loader is now struct-mode (a typo'd key raises). Resume then requires the same world size (the trainer refuses otherwise). | NCCL / 4-rank / 7B unrun. The GRPO `[rollout]` table still filters silently through `__dataclass_fields__`. | | health knobs | done | `max_wall_seconds`, `max_consecutive_skipped_steps`, `max_skipped_fraction` exist on `TrainConfig`, `DpoStageConfig`, GRPO `TrainCfg`, PPO `TrainCfg` and are forwarded by each script (a field that is not copied trains at the default while the TOML says otherwise). | - | Track M, CPU-complete (P5 rows): dropless MoE core + OLMoE architecture + HF mapping with transformers-5.15 logit parity (oracle tier); `MoETrainModule` aux losses with the global-batch window buffer, router z-loss and `moe/*` telemetry in `history.jsonl` (`docs/ARCHITECTURE.md`, "Mixture of experts"); a tiny OLMoE steps, logs, checkpoints and resumes bit-exactly through the shared Trainer with per-rank optimizer files (`tests/test_olmoe_trainer_smoke.py`). P1-3 (CPU part), P1-4 (aux losses; the token-count normalisation of P0-7 is still open), P1-6 and P1-9 telemetry are covered; P1-1 (`ModelConfig.moe`), P1-7 (flat expert layout, sharded loader), P1-8 (fp32 router optimizer state), P1-9 collapse tripwire, P1-11/12/13 (GPU measurements) are open. **Open before the first multi-hour GPU run:** P0-14 (adamw8bit GPU step time at 1.8B local elements), `offload_adamw` clipping (P0-3), token-count loss normalisation (P0-7), `no_sync` (P0-5), rank-local logging (P0-6), and `kill -9` during a save on real ranks. SIGTERM on real ranks is done (see Status at 654c20c). --- ## P1: MoE core (dense-first, torch reference, tiny sizes) Goal: a mini-B / mini-A OLMoE-shaped model trains correctly on CPU and on one-to-four A10Gs, with every fast path gated against a torch reference. Order matters; P0-7 (loss normalisation) is a hard prerequisite for P1-3. | ID | Item | Acceptance test | Files | Effort | GPU? | |---|---|---|---|---|---| | P1-1 | Config surface: `ModelConfig.moe` sub-dataclass (num_experts, top_k, expert_hidden, router dtype, gating, normalize flag, lb/z weights, capacity_factor optional, shared expert optional), `qk_norm`, `norm_style`; struct-mode rejects unknown keys; `hf_config` writes/reads the fields and refuses silent reload as dense | Round-trip config test; loading an MoE config into the dense path raises | edit `config.py`, `model/hf_config.py`, `model/mistral.py:70` validate | M | no | | P1-2 | QK-norm in `Attention` (RMSNorm on q and k per head) with parity test vs a numpy reference; parameter names and HF key tables updated | Existing dense tests untouched (flag off); QK-norm forward/backward matches reference | edit `model/mistral.py:211`, `model/checkpoint.py` tables | M | no | | P1-3 | `MoEFeedForward`: fp32 router (softmax over all experts, top-k, no renorm by default), stable-argsort dropless dispatch, torch loop-over-experts oracle, index_add combine; capacity-factor path as parity oracle (capacity >= T*k equals dropless exactly); aux-loss channel through `DecoderBlock` that survives non-reentrant AC and does not count recompute passes | Dropless == capacity(inf) == per-expert loop; grads match AC on/off to bf16 tolerance; empty-expert and padding-token cases | new `model/moe.py`; edit `model/mistral.py:367-395`, `train/fsdp.py:90-123` | M-L | no | | P1-4 | Aux losses: Switch load-balance loss (E/k formulation, weight 0.01, divided by n_layers) and router z-loss (0.001), both normalised by the same token count as CE (uses P0-7); adapters add them to `LossOutput` with metrics | Formula test (balanced routing gives 1.0); invariance to micro-batch split | new `model/moe_loss.py`; edit `train/sft_loss.py`, `train/train_module.py:44-69` | S-M | no | | P1-5 | LM-head z-loss (1e-5) in chunked fused CE, adding `mult*lse^2` to the objective and its gradient; re-gate | Matches eager reference; gate artifact regenerated | edit `kernels/fused_ce.py`, `kernels/gate.py` (parameterise by kernel name first) | S | no (reference) / **GPU** for the gate artifact | | P1-6 | Init: trunc-normal(0, 0.02, 3 sigma) for router, experts (incl. w2) in fp32 then cast; explicit expert init; test that no parameter is left uninitialised (catches bare 3-D `nn.Parameter`s `apply()` skips); depth-scaled option | Test enumerates all params and asserts non-garbage statistics | edit `model/mistral.py:559-566`, `model/moe.py` | S | no | | P1-7 | Checkpoint: flat expert layout `(E*h, d)`, router keys, HF-style per-expert export, strict `_remap` tables for MoE, buffer handling in sharded loader (`train_module.py:173-178` currently rejects buffers), `roundtrip_gate` for expert layout | Save/load/export round trip at world size 1 and 2; unknown key still raises | edit `model/checkpoint.py:66-98,281`, `train/train_module.py:154-199` | M | no | | P1-8 | Optimizer groups: per-name overrides (wd, quantised vs fp32 state) with strict glob matching; router and norms in fp32 state (`adamw8bit.py` quantises any tensor with >= 4096 local elements, including the 131k-element router) | Group test; router state stays fp32; single-group behaviour unchanged | edit `train/adamw8bit.py:257`, `train/offload_adamw.py`, `config.py` | S | no | | P1-9 | MoE telemetry and tripwire: per-layer expert load max/mean, fraction of experts below 0.1x mean, router entropy, router lse^2, drop fraction, per-expert grad-norm > 0 check for experts that got tokens; tripwire halts on router collapse; counters tagged to exclude AC recompute | Synthetic collapsed router fires; healthy one does not | new `train/moe_probes.py`; edit `train/probes.py` | S | no | | P1-10 | Mini-B end-to-end CPU smoke (d768 L12 E32 k4) and mini-A single-GPU smoke: loss decreases, aux losses finite, resume lossless, checkpoint round trip | CPU smoke in suite (tiny); GPU smoke records tokens/s and peak memory | `configs/moe_mini_b.toml`, `configs/moe_mini_a.toml`, `scripts/moe_smoke.py` | M | mini-A **GPU** (one device, preflight) | | P1-11 | Grouped-GEMM decision: micro-benchmark torch `_grouped_mm` vs loop on one A10G (expect the slow fallback; sm90/sm100 CUTLASS only) | Benchmark table recorded in the analysis doc | `scripts/bench_grouped_mm.py` | S | **GPU** (one device, minutes) | | P1-12 | Triton grouped GEMM (fwd, dX, dW) for SM86 with autotune pruned to num_stages <= 2 (99 KB smem), template from `reference-repos/DeepSpeed/deepspeed/moe/group_gemm_triton.py`, gated against the loop oracle for fwd and both grads, empty experts included. Only if P1-11 shows the loop is the bottleneck | Gate artifact passes on SM86; throughput >= loop; no host sync | new `kernels/grouped_gemm.py`, gate entry | L | **GPU** | | P1-13 | Parallelism decision for experts: benchmark FSDP-only experts vs EP all-to-all on 4 A10G over PCIe at 4096 tokens/micro-batch. Build the EP mesh path (HSDP substrate, `all_to_all_single` custom autograd, split-size exchange) only if FSDP-only is comm-bound in the measurement | Measured per-layer ms for both; decision recorded; if EP built: parity vs single-rank on gloo (uneven splits verified in the 2-process probe) | `scripts/bench_moe_parallel.py`; later `train/ep.py` | M (bench) + L (EP) | **GPU, exclusive, full preflight** | | P1-14 | Sliced upcycling tool (Mistral-7B-v0.3 FFN into E=16 slices of 896) as a state-dict transform. Exact-equivalence test uses forced combine weights 1.0 with k=E (softmax weights sum to 1, so plain routing does not reproduce dense logits) | CPU test on a tiny dense model: equivalence within summation-order tolerance; load result into the P1-1 model | new `scripts/upcycle_dense_to_moe.py` | M | no (conversion); **GPU** only for a 7B parity check, streaming per tensor | | P1-15 | OLMoE-1B-7B bring-up smoke: micro-batch 1x4096, adamw8bit, 4 ranks, a few hundred steps; record memory (expect ~13.5 GiB/card, my arithmetic) and real MFU | Fits; no OOM; MFU recorded. Measured: 0.53 TFLOP/s per GPU (MEASUREMENTS (h)); the 25 TFLOP/s assumption is retired | `configs/olmoe_1b7b.toml` | S | **GPU, all four free, exclusive** | Out of scope for P1: aux-loss-free bias balancing and shared experts (not in the OLMoE-1B-7B recipe; add later as flags), tensor/context/pipeline parallelism, FP8. --- ## P2: data and pretraining pipeline Goal: a resumable, world-size-invariant pretraining run on a blended corpus, with held-out eval in the loop. | ID | Item | Acceptance test | Files | Effort | GPU? | |---|---|---|---|---|---| | P2-1 | Token-addressed fixed-length dataset: flat token files + doc-ends array (datatrove layout), uint32 permutation memmap per (seed, epoch, size), dp rank takes `indices[:, rank::W]`, resume offset = tokens_processed // global_batch_tokens, dataset fingerprint (sha256 over files, sizes, vocab) compared on resume | Same global batch sequence at world size 1, 2, 4; resume at a different world size continues the same order; fingerprint mismatch refuses | new `data/token_dataset.py`, `data/token_loader.py`; edit `train/trainer.py` ResumableBatchStream | L | no | | P2-2 | Parallel, resumable tokenise-and-shard build with per-shard receipts; bounded RAM per process, no more than 2 workers during any training job | Killed build resumes from the last committed shard; peak RSS asserted under a cap; refuses to start while a training job holds the heavy lock | edit `data/pretrain_corpus.py`, `data/store.py`; new `scripts/build_pretrain.py` | L | no (CPU job, still under the load preflight) | | P2-3 | Data mixture: per-source target ratios, max repetition, Hamilton apportionment, ratio-difference report; deterministic cursor checkpointed with the model | Realised ratios within one instance of target; same seed gives same order | new `data/mixture.py` | M | no | | P2-4 | Quality layer: Gopher/C4 rule filters, exact + MinHash-LSH dedup (numpy signatures, disk-sorted, union-find), n-gram decontamination against align's eval sets, degenerate-instance filter, vocab-range check in collate | Each filter has a labelled golden set; dedup RSS bounded; decontam removes seeded leaks | new `data/filters.py`, `data/dedup.py`, `data/decontam.py` | M-L | no | | P2-5 | Doc-boundary attention masking: first via per-token segment ids in the SDPA path/`flex_attention` on SM86 (validate JIT under the vendored toolchain), then optionally in align's Triton kernel (block-skip assumption at `kernels/flash_attention.py:114-136` must be reworked and re-gated) | Packed batch equals the same documents run separately; no cross-document leakage in a gradient test | edit `model/attention.py`, `model/mistral.py`, optional `kernels/flash_attention.py` | M-L | **GPU** (SM86 JIT, block sizes unmeasured) | | P2-6 | Schedules and groups: WSD, linear, alpha_f floor, stateless token-denominated schedule that supports extending the horizon on resume | Schedule values match OLMo-core formulae at sampled steps; resume with changed horizon honoured | edit `train/trainer.py:157-212`, `config.py` | S | no | | P2-7 | In-loop held-out NLL eval at cadence plus eval-on-finish; spike-recovery workflow (`steps_to_skip` ranges, `hard_stop`) | Eval cadence test; skipped ranges keep token accounting | edit `train/trainer.py`; new `eval/lm_eval.py` | M | no / **GPU** for the first real run | | P2-8 | From-scratch init flow: meta-device build, post-FSDP sharded materialisation with deterministic per-parameter seeding | Same weights regardless of world size (CPU gloo test at tiny size) | edit `model/mistral.py:515-530`, `train/fsdp.py`, `train/stage_smoke.py` | M | **GPU** for 7B-class | | P2-9 | Generic byte-level BPE tokenizer with a `unicodedata`-based pre-tokenizer (OLMoE vocab 50,304 class), oracle test against the reference tokenizer in the legacy venv; confirm token dtype handling above 32,768 | Byte-identical on a held-out text corpus | new `tokenizer/bytelevel.py` | L | no | | P2-10 | Async or bounded-staging checkpoint save (only after P0-13): shard-by-shard staging capped by the 45 GB rule | Peak host RAM during save under the cap; no NCCL watchdog abort | edit `train/dcp_ckpt.py` | M | **GPU** | | P2-11 | Optional: `torch.compile` per block after AC and before FSDP; profiler callback; MFU/allocator counters in `history.jsonl`; GC and `device_peak_flops` override for A10G (OLMo falls back to an A100 table) | Compile on/off parity; profile trace written under `.tmp/` on one rank | edit `model/mistral.py`, `train/trainer.py` | M | **GPU** | --- ## Experiment P2: Goedel-Prover-V2-8B SFT with the Lean verifier in the loop (in progress) Not the data-pipeline P2 above; the label is the one the experiment brief uses. User-approved, and it runs ahead of the P4 gate that says to wait for the Leanstral report. - **Model:** `Goedel-LM/Goedel-Prover-V2-8B`, Qwen3ForCausalLM, 36 layers, hidden 4096, 32 heads / 8 KV heads, head_dim 128, intermediate 12288, vocab 151936, rope_theta 1e6, RMS eps 1e-6, untied embeddings, bf16. Snapshot: `/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/dfd02e6271a58375dfbf3ece0175277cf6b6a89a`. - **Reference code:** `/apps/development/rlhf/reference-repos/Goedel-Prover-V2`. Prompt templates, code extraction and correction prompts come from there (`src/utils.py` DeepSeekCoTHandler, `src/inference.py`). Benchmarks are `dataset/minif2f.jsonl` (244 problems) and `dataset/MOBench.jsonl` (360 problems). Sampling: temperature 1.0, top_p 0.95. - **User decisions:** (1) generation uses vLLM, in `extras/` (isolated `.venv-rollout`, vllm 0.26.0). (2) Completions are capped at **16,384 new tokens** for the main loop. A **40,960-token** setting is kept for reference baselines. Every number reported from this experiment must state its cap. - **Verifier:** `align/verify/lean` with the pins above (Lean v4.9.0-rc1, Mathlib d1d1e4b7). Eight workers at about 0.8 GB PSS each. Any Lean run with more than 2 workers takes the heavy-job flock. - **Data contract:** SFT input is built from the sampled `prompt_ids` and `completion_ids` with no re-tokenisation, and the loss is on completion tokens only. The model code is `align/model/qwen3.py` and `align/model/qwen3_checkpoint.py`. - **Status:** in progress. No results are recorded in this document yet. --- ## P3: MoE-aware RL post-training Goal: GRPO that stays stable when the policy is MoE and rollouts are long. Prerequisite: P1 complete, P0 robustness in place. | ID | Item | Acceptance test | Files | Effort | GPU? | |---|---|---|---|---|---| | P3-1 | MoE decode path in `Engine` (router + expert loop; padding tokens excluded from routing counts); engine emits per-token top-k expert ids | Engine logprobs equal trainer forward on the same tokens (existing LOGPROB_BASIS discipline) for the MoE model | edit `inference/engine.py`, `rollout/native.py` | M | **GPU** (small model) | | P3-2 | Routing replay: router accepts forced `expert_indices`, scores re-gathered at recorded indices so gate gradients survive; indices stored as int16/int32 per sample | With replay on, trainer routing equals rollout routing exactly; mismatch metric near zero; storage matches (T-1) x L x k x bytes | edit `model/moe.py`, `rl/assembly.py`, `rl/types.py` | L | no (CPU parity) / GPU for real runs | | P3-3 | GSPO sequence-level ratio, OPSM mask, behind pre-registered ablation flags (A3 conformance still refuses unregistered changes); note the length-weighting interaction with Dr.GRPO's constant denominator | Unit tests on synthetic ratios; A3 test updated; default behaviour unchanged | edit `rl/grpo_loss.py:182-262` | S | no | | P3-4 | Multiple minibatches per rollout batch (so clip, OPSM and rho actually bind); decide with data whether routing replay is needed at that point | Ratio is 1 on the first minibatch and not on later ones; clip fraction logged | edit `rl/batch_source.py:296-307`, `rl/grpo_module.py` | M | no | | P3-5 | Rollout correctness guards: refuse top_p < 1 / top_k > 0 in RL configs; truncation fraction metric and optional drop/mask of `finish_reason != stop`; bf16 log-softmax fix or measured justification; mismatch-KL metric | Config refusal test; metrics present | edit `rl/stage_config.py`, `rl/assembly.py`, `rl/grpo_module.py` | S | no | | P3-6 | In-memory trainer-to-engine weight sync over torch.distributed (layer-by-layer `full_tensor` + copy into existing engine parameters), replacing the 13.5 GB checkpoint write per sync; layer-grouped for MoE | Engine output equal before/after sync to the checkpoint path; peak host RAM under cap | edit `rollout/native.py:66-90`, `rl/batch_source.py` | M | **GPU, full preflight** | | P3-7 | Bounded-staleness async pipeline: `policy_version` on request/result, bounded queue with drop rule, producer overlapped with training; exactly-once result accounting | Staleness never exceeds K in a deterministic fake-engine test; resume loses no samples | edit `rollout/protocol.py`, `rl/batch_source.py` | M | **GPU** to measure overlap benefit | | P3-8 | Eval and curriculum: unbiased pass@k, stop-rate and length stats, named eval sets; oversampling top-up to a constant live-group count with per-prompt pass-rate memory | pass@k matches the closed form on synthetic data; batch size constant under 50% dead groups | edit `rl/canary.py`, `rl/batch_source.py` | M | no | | P3-9 | MoE RL telemetry into the GRPO tripwire framework (router entropy, expert load); decide whether to keep aux loss during RL by ablation, not by assumption (trl keeps it, open-instruct GRPO apparently does not) | Tripwire fires on injected router collapse | edit `rl/tripwire.py` | S | no | --- ## P4: formal-proof track (placeholder; design waits for the Leanstral 1.5 research report) Do not start model-specific work until `/apps/development/rlhf/research/notes/` contains the Leanstral 1.5 report. The items below are generic enablers that hold for any Lean-4 prover regardless of what that report says. | ID | Item | Acceptance test | Files | Effort | GPU? | |---|---|---|---|---|---| | P4-1 | Lean verifier environment via stdlib `subprocess`/HTTP (no kimina client): assemble statement + proof without slicing at the first `:=`, per-call timeout, memory cap per child, concurrency bounded by the host preflight, failure taxonomy (`no_code`, `lean_`, `timeout`, `crash`) mapped to reward 0 | Adversarial suite must be rejected: `sorry`, added axioms, `native_decide`/unsafe tricks, statement edits, helper-lemma-before-theorem handled correctly, hung process, huge output | new `align/verify/lean/` package (`strict.py`, `repl_pool.py`, `reward.py`, `toolchain.py`), `tests/test_lean_verifier.py` (Lean binary tests marked and skipped when absent). Landed in 654c20c, see Status | L | no GPU; needs a Lean toolchain installed under `/apps` | | P4-2 | Generic sandboxed-subprocess harness (spawn, timeout, kill, classify) shared by Lean and code execution | Fuzz test: no zombie, bounded RSS | new `align/verify/sandbox.py` | M | no | | P4-3 | Environment abstraction (reset/step/state, exactly-once episode result: episode, failure, or cancellation) | Fault-injection test | new `align/rl/env.py` | M | no | | P4-4 | Multi-turn rollout with per-token loss mask (observation tokens masked, token ids concatenated never re-tokenised) | Mask test; GRPO loss ignores masked tokens | edit `rollout/protocol.py`, `rl/assembly.py:126-150`, `inference/engine.py` | L | **GPU** for engine continuation | | P4-5 | Rejection-sampling / expert-iteration script: generate, verifier-filter (reward == 1), dedup, write SFT shard with per-prompt pass rate | Selection invariants on a fixture | new `scripts/expert_iter.py` | S | no / GPU for generation | | P4-6 | Long-context inference: prefix sharing for the G samples (copy KV rows first), then paged KV and chunked prefill if 8k+ completions are required; KV capacity budget stated per card (about 64k tokens across all slots with 7B bf16, derived estimate) | Same logprobs as contiguous path; capacity gain measured | edit `inference/engine.py` | M then L | **GPU** | | P4-7 | Multi-turn chat template and tool-call serialisation (port of the V3 path from mistral-common, oracle-tested), including training that the control-token embedding rows are not zero in the base weights (check `[TOOL_CALLS]` and friends before SFT) | Token-identical to mistral-common in the legacy venv | edit `data/schema.py`, `tokenizer/core.py` | M | no | | P4-8 | Document masking and long-context training validated for the target's context length; context parallelism only if the report requires it and only via an in-repo kernel (ring_flash_attn is banned) | Decided after the report | n/a | n/a | n/a | --- ## Suggested order of attack 1. P0-1, P0-2, P0-3, P0-4, P0-8, P0-9, P0-10 in one sitting (all S, CPU-only, high value). 2. P0-5 and P0-6 (verify first), then P0-7 (interface change, needed by MoE). 3. P0-12, P0-14 as one exclusive GPU session after the preflight; then P0-13 (the largest P0 item). 4. P1-1 through P1-10 on CPU; P1-11 and P1-15 benchmarks in one GPU session; only then decide P1-12 and P1-13. 5. P2 items 1, 2, 3, 6, 7 as the minimum for a resumable blended pretrain run; the rest as needed. 6. P3 and P4 when the target model and the Leanstral report are known.