# align architecture v2: proposal Status: proposal only. No code was moved or edited to write it. Date: 2026-10-14. Scope: `/apps/development/rlhf/align` at HEAD `1c483cc`, compared with `reference-repos/AgileRL` at `4f07424` (Apache-2.0). How to read the evidence labels used below. - **[read]** read in source; the path and line are given and can be re-checked with `sed -n`. - **[calc]** arithmetic from numbers that are themselves labelled. The arithmetic is shown or reproducible in five lines. - **[cpu-probe]** a small CPU run done for this document (the probe is described where it is used; nothing was left in the repo). - **[assumed]** a number taken from a datasheet or a typical value, not measured on this box. Every one is listed in 5.1. - **[paper]** arXiv id plus section or equation, checked against the text fetched into the hyperresearch vault. Independent verification pass (2026-10-14, builder A2): the freeze closure, the layer assignment, the shim mechanics, the source-pinning tests and the Section 5 arithmetic were re-derived from the repo; corrections are folded into the text below and marked **[A2]**. Nothing here was measured on a GPU (the P2 chain owns all four). Section 5 is therefore a model with stated assumptions and a measurement plan, not a benchmark. The CPU test suite was not run for this document either; the three docs that quote a test count disagree (1089 in DEPENDENCY_POLICY, 1107 in REFERENCE_GAP_ANALYSIS, 1608 in README), so step M0 records the real baseline first. --- ## 0. Summary of decisions 1. **Adopt from AgileRL: interfaces and contracts, not structure.** Seven ideas are worth taking (1.3): a typed, validated experience container; algorithm variants as a thin switch over one implementation (CISPO and GSPO are `GRPO` with a fixed `loss_type`); importance-sampling level and advantage granularity as two independent axes; a text-env protocol separated from a token-level rollout harness; an async collector; a documented-constructor AST test; a shard-runtime seam for dense versus FSDP2. 2. **Do not adopt its structure or its dependencies.** The 6,398-line `agilerl/algorithms/core/base.py` makes the algorithm own generation, the optimizer step, vLLM colocation, FSDP and LoRA plumbing. align's `Trainer` / `TrainModule` split is already better. accelerate, transformers, peft, vLLM, liger-kernel, pydantic, hydra, wandb, dill-pickled checkpoints and the evolutionary HPO stack are all out (1.4). 3. **The one structural gap in align is the environment.** `GrpoBatchSource` is hard-wired to `{problem, answer}` rows plus an outcome verifier; the multi-turn Lean path is a subclass that re-implements `bind`. A single `Env` protocol removes the fork. 4. **Layering is currently violated in 5 places by lazy imports** (2.1; **[A2]** one is an artefact of putting `eval` at L5, two are missed ones). They are cheap to fix and a 40-line AST test keeps them fixed. 5. **Migration is facade-first.** New packages (`algorithms/`, `envs/`, `recipes/`, `optim/`) are added as thin layers over the old modules. Old modules move only after the P2 chain exits, with identity-preserving `sys.modules` shims (verified in 4.2). **[A2]** 105 of the 129 modules in `align/` are in the import closure of the running P2 scripts and of the GPU-validation steps the chain scripts launch; they cannot be touched for now (4.1). 6. **Kernels: only one custom Triton kernel pays a lot, and it is not one of the three the task listed first.** - Paged-attention decode (`TritonPagedAttention`, an interface with no kernel today): about 2x to 4x on decode steps at batch 6 to 64 [calc, 5.4-C; **[A2]** rows that exceed the KV capacity of the card were removed]. This is the big one, because rollout generation dominates an RL iteration (5.2). - A fused head + policy-gradient loss pays about 4x on the head (about 13% of an RL *training* step) but the gain comes from precision path and GEMM count (4 fp32 GEMMs to 2 bf16 GEMMs), **not** from fusing elementwise ops. The elementwise part is about 4% of today's head time and 10% to 16% of the fused head's time **[A2]**. Do it as a chunked `autograd.Function` first; Triton is optional there (5.4-A, 5.4-B). - Fusing only the per-token CISPO/GRPO loss arithmetic is worth nothing (64 KB tensors). --- ## 1. AgileRL: what to adopt and what not to ### 1.1 What AgileRL is An evolutionary-HPO RL library (populations, mutation, tournament selection) that grew an LLM fine-tuning half. Size by directory [read, `find ... | xargs cat | wc -l`]: | dir | lines | relevance to align | |---|---|---| | `algorithms/` | 27,282 | `core/base.py` 6,398; `grpo.py` 2,471; `ppo_llm.py` 1,415; `reinforce_llm.py` 1,125; `dpo.py` 692; `sft.py` 410; `cispo.py` 26; `gspo.py` 23. The rest is classic RL (DQN, TD3, MADDPG, bandits) | | `utils/` | 17,179 | `llm_utils.py` 3,035, `algo_utils.py` 2,771, probe envs, mutation utils | | `modules/` + `networks/` + `wrappers/` + `vector/` + `hpo/` + `population.py` | 15,612 | evolvable networks and populations; none of it applies | | `llm_envs/` + `training/` + `strategies/` + `builders/` + `models/` | 11,496 | the environment and trainer side; partly relevant | | `distributed/` | 3,112 | FSDP2 runtime, process helpers | | `algorithms/core/llm_ops/` | fused ops | `fused_logprobs.py`, `fused_loss.py` (Liger), `fused_lora.py`, `moe_lora.py` | Roughly 40% of the code is classic-RL or evolution machinery that an LLM post-training library does not need. That is the first reason not to copy its layout wholesale. ### 1.2 How its LLM algorithms are built **Class tree** [read]. `EvolvableAlgorithm` (`algorithms/core/base.py:436`, metaclass `RegistryMeta` at `:312` runs `_registry_init` after every constructor) holds mutation registry, metrics, accelerator and device. `LLMAlgorithm` (`base.py:2687`, about 3,700 lines of the file) adds: actor/reference/critic adapters, vLLM colocation and weight sync (`_sync_actor_to_vllm` `:5466`, `_generate_with_vllm_colocate` `:5484`), FSDP shard runtime, fused logprob forward (`_fused_forward` `:4844`, `_fused_packed_forward` `:4915`), LoRA attach (`_attach_lora_adapters` `:4341`), backward and grad clip (`_backward_pass` `:5283`), checkpointing (`save_checkpoint` `:3157`). **Algorithms**. `GRPO(LLMAlgorithm)` (`grpo.py:170`; ctor `:398` with about 50 parameters; `get_action` `:541` generates; `learn` `:634` runs the epoch/minibatch loop, loss, backward and step). `CISPO(GRPO)` (`cispo.py:15`) and `GSPO(GRPO)` (`gspo.py:15`) are about 25 lines each: they call `super().__init__(..., loss_type="cispo")`. The objective is chosen in `_setup_objective` (`grpo.py:1071`): `loss_type in {grpo, gspo, cispo}`, `importance_sampling_level in {token, turn, trajectory}` (gspo forces `trajectory`), `advantage_granularity` separately, `loss_norm in {micro_batch, accumulation_window}`. `DPO` (`dpo.py:44`) and `SFT` (`sft.py:42`) follow the same `get_action` / `learn(experiences)` shape with a typed `experiences` argument. **ILQL and BC_LM are a second generation.** `ilql.py:42` is `class ILQL(nn.Module)` of 2,256 lines, a port of an older offline-RL codebase with its own `Language_Environment` / `RL_Dataset` types, and `import wandb` at module top (`ilql.py:15`). It does not use `LLMAlgorithm`. So AgileRL itself carries two incompatible LLM-algorithm architectures. That is the cost of growing a base class instead of defining a contract first. **Experience container** [read, `components/llm_rollout_data.py`]. `Trajectory` (frozen pydantic model: `token_ids` `(1,T)`, `action_masks`/`turn_ids` `(1,T-1)`, `rewards` per turn, optional `sampling_logps`, `pixel_values`) with a `model_validator` that fails when per-token fields are not `T-1` long. `RolloutGroup` requires exactly `group_size` trajectories. They are packed into `LLMExperienceBatch`. The label-aligned `T-1` convention is the same one align states in `rl/types.py`. **Environments** [read]. `protocols.py:602` `TextEnvProtocol` (`reset(...)`, `step(action_text)`, `system_prompt`) and `:624` `EnvClientProtocol` (Gym five-tuple, `dataset_size`, `tools`, `rubric_components`, `eval_mode()`). `RolloutHarness` (`llm_envs/harness.py:44`, 857 lines) does tokenisation, chat-template boundaries and the turn loop over a client. `RolloutCollector` (`collector.py:28`) runs many harnesses; `TaskAssigner` (`task_assigner.py:27`) assigns dataset rows to slots; `AsyncBatchCollector` (`async_collector.py:24`) overlaps collection with learning; `rubrics.py` composes reward components. **Training loop and config** [read]. `training/llm/rollout.py:100` `train_llm_rollout(pop, max_turns, env_factory, ...)`. Config is YAML under `configs/training/llm_finetuning/` (cispo, dpo, grpo, gspo, ppo_llm, reinforce_llm, sft) parsed into pydantic `*Spec` models (`models/`, `builders/llm.py:46` `LLMBuilder`). **Checkpointing** [read, `base.py:3157-3250`]. A directory with PEFT adapter dirs plus `attributes.pt`, a pickle written with `dill` (`base.py:33`, `:1366`, `:1381`). FSDP2 state is gathered to full tensors before saving, so checkpoints are rank-count independent. Only rank 0 writes. **Distributed** [read]. `accelerate` for process setup, plus a native FSDP2 layer: `distributed/runtime.py` defines a shard runtime (`prepare_actor`, `export_model_state`, `export_optimizer_state`) with dense and FSDP2 implementations. **Tests and docs** [read]. `tests/` mirrors the package (`test_algorithms/test_llms`, `test_llm_ops`, `test_llm_envs`, ...). `test_algorithms/test_param_docstrings.py` is an AST test: every constructor parameter of an audited class has a `:param:` entry and no stale entries remain. Sphinx docs under `docs/llm_finetuning/` (batch sizing, fused logprobs, checkpoints, environments). **Convergent design worth noting.** AgileRL's `fused_logprobs.py` (`fused_linear_logprobs_chunked` `:135`, `FusedLinearLogProbsFunction` `:199`) is the same chunked head + log-softmax + gradient-checkpoint idea as align's `kernels/fused_ce.py` and `rl/cispo.py::fp32_head_logprobs`. AgileRL's own GRPO docstring says its Liger GRPO kernel "shows no speedup over AgileRL's already memory-bounded standard path" (`grpo.py`, `use_liger_loss` parameter doc). That matches the roofline result in 5.4-A: chunking removes the memory problem, and a fused elementwise kernel adds almost nothing on top. ### 1.3 Ideas to adopt | # | Idea | AgileRL reference | align today | Concrete action | |---|---|---|---|---| | A1 | Typed, validated experience batch with a shape-contract validator | `components/llm_rollout_data.py` `Trajectory`, `RolloutGroup` | `rl/types.py` `RolloutBatch.__post_init__` (`:51`) already validates; `TokenAdvRolloutBatch` lives in `rl/grpo_loss.py:301`; `DpoBatch` in `train/dpo_stage.py:818`; SFT batch is an untyped dict from `collate_sft` | One module `align/batch.py` holding `SFTBatch`, `PreferenceBatch`, `RolloutBatch`, `TokenAdvRolloutBatch`, `PPORolloutBatch`, each with `validate()`. Dataclasses, not pydantic | | A2 | Variants as a thin switch, not copies | `cispo.py` / `gspo.py` (25 lines each) | `GRPOLossType.{dapo,cispo}` in `rl/grpo_loss.py:41` already does it for the loss; GSPO absent | `algorithms/grpo/` owns one implementation; `algorithms/cispo.py` and `dapo.py` are presets (2.3). Add the trajectory-level ratio as a third option only when needed (GSPO, arXiv:2507.18071 Sec. 4.1 Eq. 5-7: `s_i = exp(mean_t log(pi/pi_old))`) | | A3 | Two independent axes: importance-sampling level and advantage granularity | `grpo.py:1071` `_setup_objective`, `:1493` `_resolve_advantage_granularity` | `rl/multiturn_source.py` computes per-turn advantages, `expand_advantages` (`grpo_loss.py:234`) broadcasts; ratio level is token only | Name both axes in `GRPOConfig` (`ratio_level`, `advantage_level`) and refuse the mismatched pair that AgileRL only warns about (`grpo.py` warns on `turn` advantage with `trajectory` ratio) | | A4 | Text-env protocol separate from the token-level harness | `protocols.py:602`, `llm_envs/harness.py:44` | `rl/envs/lean_multiturn.py` fuses both; `LeanMultiTurnEnv.run_group(specs, generate)` already takes the generator as a callable, which is the right seam | `align/envs/` (2.4): `Env` protocol over text, one `Harness` for tokenisation and per-turn masks, `MathEnv`, `LeanEnv`, `CodeEnv` | | A5 | Async collection overlapping learning | `llm_envs/async_collector.py:24` | rollout is synchronous (`rl/batch_source.py` `_produce_step`); REFERENCE_GAP_ANALYSIS 1.3 already lists this | Defer. Design the `RolloutBackend.submit/collect` protocol (already present in `rollout/protocol.py:95`) so a one-step-stale pipeline is a source-level change, not an engine change | | A6 | Documented-constructor AST test | `tests/test_algorithms/test_param_docstrings.py` | align documents fields as `#:` comments on dataclasses (`config.py`) but nothing checks it | `tests/contract/test_config_docs.py`: every field of every registered algorithm Config has a doc comment. Stdlib `ast`, runs in the firewall lane | | A7 | Shard-runtime seam (dense vs FSDP2 vs TP) | `distributed/runtime.py` | `TrainModule.full_model_state_dict`, `optim_state_dict`, `load_*` already are this seam (`train/train_module.py`), plus `model/tp.py` for inference TP | Name the seam `ShardRuntime` in `align/protocols.py` and make `TrainModule` and `TPPagedEngine` implement it; no code change to the Trainer | Also adopted as a habit rather than code: AgileRL keeps fused ops in their own package with their own tests (`algorithms/core/llm_ops/`, `tests/test_algorithms/test_llm_ops/`). align already does this in `kernels/`. ### 1.4 Ideas not to adopt | Idea | AgileRL reference | Why not | |---|---|---| | A 6,398-line base class that owns generation, vLLM colocation, FSDP, LoRA and the optimizer step | `algorithms/core/base.py` | The algorithm cannot be tested without those. align's `TrainModule` owns model+loss+optimizer, `Trainer` owns loop and state, the batch source owns rollouts. Each is testable on CPU alone (1,000+ CPU tests prove it) | | `get_action` generates inside the algorithm | `grpo.py:541` | Couples the algorithm to HF `generate` or colocated vLLM. align's `RolloutBackend` (`rollout/protocol.py:95`) keeps the engine replaceable (native, paged, spool/vLLM) | | Replacing `lm_head` with a pass-through `HiddenStateHead` (documented as `nn.Identity`) by monkeypatch to get hidden states | `base.py:6314` `_patch_lm_head_to_identity` | Mutates the module tree under a context manager. align passes `hidden_consumer(hidden, lm_head_weight)` into `forward` (`model/qwen3.py:467`, `mistral.py:597`): explicit, and it fails loudly on a model without the kwarg (`rl/cispo.py` `fp32_head_forward`) | | accelerate, transformers, peft, bitsandbytes, liger-kernel, vLLM as hard or core dependencies | `pyproject.toml` `dependencies`, `cpu-llm` extra | Violates `docs/DEPENDENCY_POLICY.md` and `tests/test_dependency_firewall.py`. align already has the replacements (own LoRA, own 8-bit AdamW, own tokenizer, own paged engine) | | pydantic spec models and hydra/omegaconf | `models/`, `builders/` | `align/config.py` is dataclass + `tomllib` with struct mode and typed dotted overrides, no dependency. Adopt only the idea that the algorithm's config is its own dataclass | | dill-pickled `attributes.pt` as the checkpoint container | `base.py:33`, `:1366` | Pickle loads arbitrary code. align reads `resume.pt` with `weights_only=True` and has fsync, partial-staging and completeness checks (`train/trainer.py`) AgileRL lacks | | Evolutionary HPO: population, mutation, tournament, `EvolvableModule` networks | `hpo/`, `population.py`, `modules/`, `networks/` | Irrelevant to single-run post-training, and it shapes the base class (`MutationRegistry`, `_registry_init` metaclass hook) | | The older `nn.Module` ILQL/BC_LM generation | `algorithms/ilql.py`, `bc_lm.py` | Shows the failure mode of not fixing the contract first (1.2) | | Arena/cloud manifests | `models/manifest.py`, `agilerl-arena` | Not a use case here | | Warn-and-continue on conflicting options | many `warnings.warn` in `_setup_objective` | align's convention is a launch refusal (`assert_a3_conformant`, `assert_p4_cispo_conformant`). Keep refusing | ### 1.5 Where align is already ahead (keep as is) - The Trainer / TrainModule split with `extra_state()` hooks and a documented resume flow (`docs/ARCHITECTURE.md`, "Trainer / TrainModule split"). - The control plane: coordinated stop, MAX-reduce decisions, exit codes 80 to 83, `HALT.json`, supervisor (`align/exit_codes.py`, `utils/supervisor.py`). - Host safety: preflight, heavy-job flock, D-state gate (`utils/guard.py`, `utils/host_state_gate.py`). - The dependency firewall as an executable test. - Launch-refusal gates per stage with a dated rationale in each docstring. - Bit-invariance as a first-class engine mode (`TorchPagedAttention(mode="row")`). None of this exists in AgileRL, and none of it should be traded for its structure. Licence note: AgileRL is Apache-2.0. Adopting ideas needs no attribution; copying code needs the header and a NOTICE entry. This proposal assumes ideas only. --- ## 2. Target package layout ### 2.1 Layer rules, and where the current code breaks them Imports flow downward only. A layer may import its own layer only through an explicit allow-list in the test. ``` L0 root modules: config, exceptions, exit_codes, registry(banner), protocols (new), batch (new) runtime/ (was utils/) L1 kernels, tokenizer, model, optim (new), functional (new: pure tensor/scalar math, no align imports) L2 verify, data, rollout, inference, envs (new) L2b eval (imports only L1/L2: data, verify, inference, model, tokenizer; measured, see below) L3 train (the engine: Trainer, TrainModule, checkpoint formats, FSDP, stage gates and smoke) L4 algorithms (new) (losses, batch contracts, modules, presets; subclass TrainModule) L5 recipes (new), cli (new) ``` **[A2] Why `eval` is L2b and not L5.** The first draft put `eval` at L5 and then listed `train -> eval` as an upward import. An AST scan of `align/eval/*.py` shows its only `align` imports are `config`, `data.schema`, `data.tokenization`, `inference.engine`, `model.*`, `tokenizer`, `verify` and itself, so `eval` sits below `train`. With `eval` at L5 the proposed fixes created two new violations (moving `train/stage_gates.py` to `recipes/` makes `dpo_stage`, now L4, import L5; moving `ref_cache` to `algorithms/dpo/` leaves `ref_cache -> eval.metrics.wilson_interval`, L4 to L5) and moved a 955-line, 12-import-site, frozen file for no gain. With `eval` at L2b, `stage_gates.py`, `stage_smoke.py` and `ref_cache.py` need no layering fix. The `align/__init__.py` lazy public API is exempt from the layer rule (it names L2 modules from L0 through `__getattr__`). Edges measured with an AST scan of every `import` including lazy ones [read, script run for this document]: | Violation | Evidence | Fix | |---|---|---| | ~~`train` imports `eval`~~ **[A2] not a violation** once `eval` is L2b (see above) | `train/stage_smoke.py:369,370,453,463`, `train/ref_cache.py:594`, `train/stage_gates.py:351,685` | none: `stage_gates`, `stage_smoke` stay in `train/`; `ref_cache` moves to `algorithms/dpo/` and may import `eval` | | `train` imports `ppo` (upward) | `train/qwen3_stage.py:171,189,209,224,258` (`ppo.lora`) and `:333` (`ppo.trainer.AdapterProbeView`) **[A2: the first draft listed only 171,189,209 and missed the `AdapterProbeView` edge]** | LoRA is a model concern: `ppo/lora.py` to `model/lora.py`; `AdapterProbeView` is a parameter view for `train.probes.TrunkMovementProbe`, so it moves next to it, `ppo/trainer.py::AdapterProbeView` to `train/probes.py` | | **[A2] new:** `rl/envs/lean_multiturn.py` (becomes `envs/`, L2) imports `rl.advantages` and `rl.rewards` (would be `algorithms/`, L4) | `lean_multiturn.py:100,101` (`compute_group_advantages`, `soft_overlong_punishment`) | `rl/advantages.py`, `rl/rewards.py`, `rl/ops.py`, `ppo/gae.py` have no `align` imports (checked); they become `functional/{advantages,rewards,ops,gae}.py` at L1 instead of `algorithms/common/` | | `data` imports `eval` while `eval` imports `data` (cycle) | `data/outcome_agreement.py:275,306` imports `eval.verifier`; `eval` has 3 edges into `data` | `Outcome`/`OutcomeVerifier` to `verify/outcome.py`; `outcome_agreement.py` to `eval/` | | `verify` imports `eval` while `eval` imports `verify` (cycle) | `verify/lean/reward.py:116` imports `eval.verifier.Outcome` | same move | | The library loads code from `scripts/` and `extras/` by file path | `rl/envs/lean_multiturn.py:108-123` (`_load_by_path("...", "extras/vllm_rollout/lean_prompts.py")`, `"scripts/p2/p2_common.py"`) | Vendor the prompt module as `envs/lean/prompts.py` with a sha256 identity test against the `extras/` copy; extract the one function used from `p2_common` (`displayed_statement_matches`) | | Hard-coded absolute paths in library code | `config.py:40,335,339`, `kernels/gate.py:36`, `kernels/_toolchain.py:42`, `utils/guard.py:41,42,43` (`PROJECT_ROOT = "/apps/development/rlhf/align"`); `eval/golden.py:84,123,128,129,374`; `verify/lean/toolchain.py:39`; `rollout/client.py:135`; `data/hub.py:54`, `data/sources.py:37`, `data/ingest_parquet.py:36`; `train/dpo_stage.py:142,149` (**[A2]** the last three groups were missing; `config.py:335,339` too) | M8; blockers for open-sourcing. **Editing `eval/golden.py` changes `eval_source_hash` (4.5)** | The enforcing test (`tests/test_layering.py`, about 40 lines) builds the same graph, maps each module to a layer from a table in the test, and fails on an upward edge or on a cycle between two same-layer packages. **[A2]** It scans only real modules: files listed in the shim manifest (4.2) are skipped, and the legacy packages `ppo`, `rl`, `rl.envs`, `utils` are treated as aliases of their targets, otherwise every shim would be reported as an upward edge. It starts in report-only mode (M0) so the current violations are recorded, then becomes strict as each is fixed. ### 2.2 Tree ``` align/ __init__.py lazy public API (existing pattern; new names added in M7) config.py exceptions.py exit_codes.py registry.py unchanged location (L0) protocols.py NEW Algorithm, Env, RolloutBackend, Verifier, ShardRuntime, WeightFormat, LossAdapter batch.py NEW SFTBatch, PreferenceBatch, RolloutBatch, TokenAdvRolloutBatch, PPORolloutBatch ([A2] both files import torch only under TYPE_CHECKING or inside functions; neither is imported by `align/__init__`, and `test_public_api`'s heavy-module list gains `align.batch`, `align.protocols`) runtime/ dist.py guard.py host_state_gate.py supervisor.py tracking.py (from utils/) __init__.py stays empty: `import align.runtime` must not import torch (tests/test_public_api.py) functional/ NEW (L1) advantages.py rewards.py ops.py gae.py kl.py pure math, no align imports kernels/ unchanged + fused_policy_loss.py (NEW), paged_attention.py (NEW, Triton) tokenizer/ unchanged model/ unchanged + lora.py (from ppo/lora.py), heads.py (from ppo/value_head.py), build.py (arch registry: mistral | qwen3 | olmoe) optim/ adamw8bit.py quant8.py offload_adamw.py factory.py (make_optimizer) verify/ unchanged + outcome.py (from eval/verifier.py), code/ (NEW) data/ unchanged (minus outcome_agreement.py) rollout/ unchanged + backends.py (NEW registry: native | paged | tp | spool) inference/ unchanged (eval-time contiguous-KV engine; candidate to be replaced by PagedEngine later) envs/ NEW base.py harness.py math.py lean/{env.py,prompts.py} code.py train/ engine: trainer.py train_module.py fsdp.py probes.py (+AdapterProbeView) sharded_ckpt.py ckpt_format.py moe_module.py stage_gates.py stage_smoke.py ([A2] the last two stay) algorithms/ NEW base.py registry.py sft.py dpo/ ppo/ grpo/ cispo.py dapo.py expert_iteration.py eval/ unchanged + outcome_agreement.py (M7 only: see 4.5, changes eval_source_hash) recipes/ NEW sft.py dpo.py grpo.py ppo.py expert_iteration.py cli.py NEW python -m align {sft,dpo,grpo,ppo,ei,eval} ``` Not moved on purpose: `config.py`, `exceptions.py`, `exit_codes.py`, `registry.py` (55 test import sites for `config.py` alone, all in the freeze closure, and nothing is gained by a `core/` directory that the layer table does not already give); `model/`, `kernels/`, `tokenizer/`, `rollout/`, `inference/`, `data/` names (97 test imports use `align.model` alone). Tests (121 files) stay flat in `tests/`; a new `tests/contract/` holds the contract suites (2.3). ### 2.3 The algorithm contract Today the pieces of an algorithm are spread over four places with no common name: a loss adapter `(model, batch) -> LossOutput` (`train/train_module.py` `LossAdapter`), a stage config parsed by its own loader (`rl/stage_config.py:181`, `ppo/stage_config.py:313`, `train/dpo_stage.py:285`), a launch-refusal function (`assert_a3_conformant` at `rl/grpo_loss.py:337`, `assert_p4_cispo_conformant` at `rl/cispo.py:295`), and a `TrainModule` subclass. The contract names them. ```python # align/protocols.py (sketch) @dataclass(frozen=True) class Needs: reference: Literal["none", "adapters_off", "cached_logprobs", "model"] rollouts: bool critic: bool class Algorithm(Protocol): name: ClassVar[str] Config: ClassVar[type] # frozen dataclass, struct-mode loadable by align.config Batch: ClassVar[type] # batch contract in align/batch.py, with .validate() needs: ClassVar[Needs] def __init__(self, cfg: "Config"): ... def validate(self, run: RunConfig, world_size: int) -> list[str]: """Launch refusals. Empty list = may launch. Replaces assert_a3_conformant et al.""" def loss_adapter(self) -> LossAdapter: # (model, batch) -> LossOutput def train_module(self, model, optimizer, run: RunConfig) -> TrainModule: ... def make_source(self, data_cfg, *, env=None, engine=None, model=None, tokenizer=None): ... def extra_state(self) -> dict: ... # rides resume.pt["extra_state"] (exists) def load_extra_state(self, state: dict) -> None: ... def banner(self) -> dict: # keys registered with align.registry ``` Rules the contract encodes, each already true somewhere in align and now stated once: `train_batch` is exactly `(loss / grad_accum).backward()`; per-token tensors are label-aligned `[B, T-1]`; extra state loads with `weights_only=True`; undefined metrics are NaN, never 0.0. Mapping of the seven algorithms (current locations [read]): | Algorithm | Loss today | Batch contract today | Config today | Extra state | Needs | |---|---|---|---|---|---| | `sft` | `make_sft_loss_adapter` `train/sft_loss.py:54` over `fused_linear_cross_entropy` (`kernels/fused_ce.py:259`) | dict from `data.loader.collate_sft` | `RunConfig` sections only | none | none | | `dpo` | `make_dpo_loss_adapter` `train/dpo_loss.py:115` over `fused_dpo_loss` (`kernels/fused_dpo.py:545`) | `DpoBatch` `train/dpo_stage.py:818` | `DpoStageConfig` `:118` | tripwire response (`:680`) | `cached_logprobs` (`train/ref_cache.py`) | | `ppo` | `ppo_loss` `ppo/loss.py:83`, `value_warmup_loss` `:163`, `PpoTrainModule` `ppo/trainer.py` | `PPORollout` `ppo/rollout.py:40` | `PPOStageConfig` `ppo/stage_config.py:110` | ratio tripwire (`ppo/health.py`) | `adapters_off` reference, critic, rollouts | | `grpo` | `compute_grpo_loss` `rl/grpo_loss.py:179`, `GRPOTrainModule` `rl/grpo_module.py:53` | `RolloutBatch` `rl/types.py:21`, `TokenAdvRolloutBatch` `rl/grpo_loss.py:301` | `GrpoStageConfig` `rl/stage_config.py:92`, `GRPOLossConfig` `rl/grpo_loss.py:47` | `CollapseTripwire` state, `GrpoBatchSource` cursor (see "Still open" in DEPENDENCY_POLICY) | rollouts; reference only if `beta > 0` | | `dapo` | `GRPOLossType.dapo` branch (`grpo_loss.py:194`) + dead-group filter (`rl/advantages.py:50`) + soft overlong (`rl/rewards.py:22`) | as `grpo` | as `grpo` | as `grpo` | as `grpo` | | `cispo` | `cispo_policy_loss` `rl/cispo.py:110`, `fp32_head_forward` `:255` | as `grpo` | `GRPOLossConfig(loss_fn=cispo)` + `assert_p4_cispo_conformant` | as `grpo` | as `grpo`, `beta = 0` | | `expert_iteration` | inner loss is `sft`; outer is an 11-stage driver `scripts/p2/expert_iteration.py` (850 lines) with chain-hash markers | stage markers (`RUN/round_NN/stages/.done.json`) | argparse in the script | round manifests | none (subprocess stages) | **Two honest wrinkles in this table.** - *"DAPO" in align is not DAPO's objective.* `GRPOLossType.dapo` is the clip-higher surrogate (DAPO Sec. 3.1, arXiv:2503.14476, `eps_high = 0.272` in `grpo_loss.py:54`), but the A3 gate refuses DAPO's own token-mean denominator (`grpo_loss.py:56` comment "A3 requires a float (Dr.GRPO)"; refusal text at `:356`, "not the DAPO token-mean 'token' default") in favour of a constant denominator and centred advantages without std division (Dr. GRPO, arXiv:2503.20783, Sec. 3.1 on the length and std normalisation biases). Dynamic sampling (DAPO Sec. 3.2) is the dead-group filter; soft overlong punishment (DAPO Sec. 3.4) is `rl/rewards.py:22`. The presets should say what they are: `dapo` (upstream-faithful, token mean, for comparison), `a3` (the pre-registered clip-higher + constant denominator), `cispo_p4` (CISPO, arXiv:2506.13585 Sec. 3.1 Eq. 3-5, no lower IS bound, `beta = 0`, fp32 head per Sec. 3.2). The A3 and P4 gates stay as `validate()` and keep refusing. - *`expert_iteration` is an outer loop, not a per-step loss.* The contract has two kinds: a `LossAlgorithm` (everything above except this one) and an `OuterLoop` (a stage graph whose stages are subprocesses of other recipes). Do not force it into `loss_adapter`. It implements `validate`, `banner` and a `run(stage_graph)`; its stages call `recipes.sft`, `recipes.eval`. **Contract test** (`tests/contract/test_algorithm_contract.py`, parametrised over the registry): (1) `Config` round-trips through `dumps_toml` and `load_config` with struct mode; (2) `loss_adapter()` returns a finite `LossOutput` on a tiny fp64 model and synthetic batch; (3) the gradient equals an eager fp64 reference to a stated tolerance (the "oracle tier" pattern already used for model parity); (4) `extra_state()` survives `torch.save` / `torch.load(weights_only=True)`; (5) `Batch.validate()` rejects an off-by-one label alignment; (6) `validate()` fires on each known-bad config in a table; (7) every `Config` field has a doc comment (A6 above). ### 2.4 The environment contract ```python # align/envs/base.py (sketch) GenerateFn = Callable[[list[GenRequest]], list[Generation]] # the rollout-engine seam; exists in lean_multiturn.py:152,160 class Env(Protocol): name: ClassVar[str] def specs(self, split: str) -> Sequence[EpisodeSpec]: ... def run_group(self, specs: Sequence[EpisodeSpec], generate: GenerateFn) -> list[EpisodeRecord]: """Run all episodes of one prompt-group in lock step so the engine can batch across them.""" def rows(self, ep: EpisodeRecord) -> list[TrainingRow]: """response_mask is 1 only on tokens the generator returned; turn_ids labels each token.""" def reward_components(self) -> tuple[str, ...]: ... ``` - `MathEnv` (single turn): extract answer, `OutcomeVerifier`, `CompositeReward`; absorbs `rl/assembly.py:57` `assemble_rollout`'s scoring half. - `LeanEnv(max_turns=K)`: `rl/envs/lean_multiturn.py` unchanged in behaviour; `max_turns=1` is the single-turn Lean prover. Layout `per_turn` or `concatenated` stays an env option; `PrefixBreak` stays. - `CodeEnv`: new. Run candidate code against tests in a subprocess with the same constraints as `verify/lean/sandbox.py` (own session, `prlimit`, minimal environment, memory watchdog). Counts as a heavy job for the host preflight when pooled. `GrpoBatchSource` becomes `RolloutSource(env, engine, algorithm_assembler)`; `MultiTurnBatchSource(GrpoBatchSource)` (a subclass whose `bind` is "deliberately narrower than the parent's", `rl/multiturn_source.py:418`) disappears, because the parent no longer assumes `problem/answer` rows. ### 2.5 Exact mapping, current to new `F` = in the import closure of the running P2 scripts (4.1): do not edit or move until the chain exits. For an `F` file the new path is created first as a facade that imports from the old path; the physical move and shim-direction flip happen in M7. Files not marked `F` can move in M3 to M5 with an identity shim. | Current | New | F | |---|---|---| | `align/__init__.py` | stays; new lazy names in M7 | F | | `align/config.py`, `exceptions.py`, `exit_codes.py`, `registry.py` | stay | F | | (new) | `align/protocols.py`, `align/batch.py` | | | `utils/dist.py`, `utils/guard.py`, `utils/tracking.py` | `runtime/dist.py`, `runtime/guard.py`, `runtime/tracking.py` | F | | `utils/host_state_gate.py`, `utils/supervisor.py` | `runtime/host_state_gate.py`, `runtime/supervisor.py` | | | `utils/__init__.py` | stays (shim package) | F | | `kernels/*` | stay; new `fused_policy_loss.py`, `paged_attention.py` | mixed | | `tokenizer/*` | stay | F | | `model/{attention,checkpoint,hf_config,mistral,moe,moe_ep,olmoe,olmoe_checkpoint,qwen3,qwen3_checkpoint}.py` | stay | F | | `model/{gates,weight_average}.py` | stay | | | `model/tp.py` **[A2]** | stay | F (imported by `tests/test_tp_bench.py`) | | `ppo/lora.py` | `model/lora.py` | F | | `ppo/value_head.py` | `model/heads.py` | F | | `train/qwen3_stage.py` (`build_qwen3_stage_model`, `resolve_qwen3`, `validate_qwen3_cfg`, `qwen3_trainer_extras`, `qwen3_entry_banner`) | `model/build.py` (builders) and `recipes/sft.py` (banner, extras) | F | | `train/qwen3_stage.py` `LoRASFTModule` | `algorithms/sft.py` (`LoRASFTModule`) | F | | `train/qwen3_stage.py` `Qwen3Format` | `train/ckpt_format.py` (next to `DenseFormat`, `OlmoeFormat`) | F | | `train/{adamw8bit,quant8,offload_adamw}.py` | `optim/{adamw8bit,quant8,offload_adamw}.py`; `make_optimizer` to `optim/factory.py` | F | | `train/{trainer,train_module,fsdp,probes,sharded_ckpt,ckpt_format,moe_module}.py` | stay (the engine); `probes.py` gains `AdapterProbeView` from `ppo/trainer.py` | F | | `train/stage_gates.py`, `train/stage_smoke.py` | **[A2] stay** (they import only `eval`, which is L2b; moving them to `recipes/` made `dpo_stage` import L5 and touched 12 frozen import sites for nothing). `recipes/` imports them | F | | `train/__init__.py`, `eval/__init__.py`, `model/__init__.py`, `rollout/__init__.py`, `kernels/__init__.py`, `verify/__init__.py`, `tokenizer/__init__.py`, `data/__init__.py`, `inference/__init__.py` **[A2]** | stay | F | | `ppo/__init__.py`, `rl/__init__.py`, `rl/envs/__init__.py` **[A2: had no destination]** | stay as legacy alias packages. `ppo/__init__` and `rl/__init__` import 9 and 10 submodules eagerly (names in `__all__`), so after M7 each re-export line goes through a shim: they are rewritten as lazy `__getattr__` re-exports to the new locations, in M7, with a test that `from align.rl import GRPOTrainModule` still works and that `import align.rl` imports no more than it does today | F (`rl/envs/__init__` free) | | `train/sft_loss.py` | `algorithms/sft.py` | F | | `train/dpo_loss.py` | `algorithms/dpo/loss.py` | | | `train/dpo_stage.py` | `algorithms/dpo/{config,gates,source,adapter}.py`; `DpoBatch` to `batch.py` | | | `train/ref_cache.py` | `algorithms/dpo/ref_cache.py` | | | `rl/types.py`, `TokenAdvRolloutBatch` from `rl/grpo_loss.py` | `batch.py` | F | | `rl/grpo_loss.py` | `algorithms/grpo/loss.py` | F | | `rl/grpo_module.py` | `algorithms/grpo/module.py` | F | | `rl/advantages.py`, `rl/rewards.py`, `rl/ops.py` | `functional/{advantages,rewards,ops}.py` **[A2: were `algorithms/common/` and `algorithms/grpo/`, which put an L4 module under the L2 `envs/lean`]** | F | | `rl/tripwire.py`, `rl/canary.py` | `algorithms/grpo/{tripwire,canary}.py` | F | | `rl/cispo.py` | `algorithms/cispo.py` (loss and conformance); `fp32_head_*` stay as the reference path for `kernels/fused_policy_loss.py` | F | | `rl/stage_config.py` | `algorithms/grpo/config.py` | | | `rl/batch_source.py`, `rl/assembly.py`, `rl/format_instrument.py` | `algorithms/grpo/{source,assembly,format_instrument}.py` | | | `rl/multiturn_source.py` | `algorithms/grpo/multiturn.py` | | | `rl/envs/lean_multiturn.py` | `envs/lean/env.py`; prompts vendored to `envs/lean/prompts.py` | | | `ppo/gae.py` | `functional/gae.py` | F | | `ppo/{loss,rewards,rollout,health}.py` | `algorithms/ppo/{loss,rewards,rollout,health}.py` | F | | `ppo/trainer.py` | split: `algorithms/ppo/module.py` (`PpoTrainModule`), `algorithms/ppo/source.py` (`PPOBatchSource`), `AdapterProbeView` to `train/probes.py` | F | | `ppo/stage_config.py` | `algorithms/ppo/config.py` | | | `scripts/p2/expert_iteration.py` | library form `algorithms/expert_iteration.py`; script stays and later becomes a wrapper | frozen | | `eval/verifier.py` | `verify/outcome.py` (old path becomes a shim). **[A2] changes `eval_source_hash`, see 4.5** | F | | `eval/{golden,harness,metrics,proof_stats,roundtrip}.py` | stay | F | | `data/outcome_agreement.py` | `eval/outcome_agreement.py` (**[A2]** adds a file to `align/eval/`, so it changes `eval_source_hash` too: M7 only) | F | | `data/*` (rest) | stay | mixed | | `verify/{equiv,expr,extract,normalize}.py`, `verify/lean/*` | stay | F (lean frozen by instruction) | | `rollout/protocol.py` | stays | F | | `rollout/{engine,paged,tp_engine}.py` **[A2]** | stay; `paged.py` gets `TritonPagedAttention` in M6 | F (imported by `tests/test_tp_bench.py`, which the chain launches) | | `rollout/{client,native}.py` | stay; new `rollout/backends.py` | | | `inference/{engine,bucketed}.py`, `inference/receipts.py` | stay | F / free | | `scripts/{sft,dpo,grpo,ppo}.py` | stay; `recipes/*` hold the same flows; scripts become 30-line wrappers after M7 | F (sft) | --- ## 3. Minimal public API Ten call sites. Names marked `exists` are in the repo today; `new` are the facade layer from M1/M2. The point is that `scripts/sft.py` (261 lines), `scripts/grpo.py` (412) and `scripts/ppo.py` (482) are each one use of these ten. ```python import align # 1. config (exists: align.config.load_config). The loader splits [algo], [env], [rollout] before struct-mode parsing, so # config.py itself is not edited during the freeze. run = align.load_run("configs/p4_cispo_lean.toml", overrides=["optimizer.lr=1e-6"]) # new, wraps load_config # 2. host guards, before torch (exists: align.utils.guard.apply_resource_guards) align.runtime.apply_guards(cpu_threads=8, heavy=True, label="cispo-lean") # 3. model + tokenizer, family chosen by run.model.arch through a registry (new; today scripts/sft.py::build_model # and train/qwen3_stage.py::build_qwen3_stage_model) model, tok = align.model.build(run) # 4. algorithm by name or preset (new; the classes wrap today's GRPOTrainModule / DpoStage / PpoTrainModule) algo = align.algorithms.create(run.algo) # run.algo = {name="grpo", preset="cispo_p4", eps_high=...} refusals = algo.validate(run, world_size=3) # launch refusals as data; recipes raise if non-empty # 5. environment (new; wraps rl/envs/lean_multiturn.py) env = align.envs.make("lean/multiturn", run.env, verifier=align.verify.lean.StrictVerifier(...)) # StrictVerifier exists # 6. rollout engine (exists: align.rollout.client.make_rollout_client; new name and registry) engine = align.rollout.make_engine(run.rollout, model=model, tokenizer=tok) # "native" | "paged" | "tp" | "spool" # 7. batch source: algorithm-specific, built from env + engine (new; GrpoBatchSource is today's equivalent) source = algo.make_source(run.data, env=env, engine=engine, model=model, tokenizer=tok) # 8. trainer: the existing Trainer, built from the algorithm (new classmethod over Trainer.__init__) trainer = align.train.Trainer.from_algorithm(run, model, algo, source, run_dir=run.paths.run_dir) trainer.resume_from_latest() # exists summary = trainer.train() # exists; honours HALT codes and coordinated stop # 9. one-liner for the common case (new) summary = align.fit(run) # steps 2-8 in order, with the stage gates from train/stage_gates.py # 10. kernels as a plain function, for people who only want the kernel (new) loss, metrics = align.kernels.fused_policy_loss( hidden, lm_head_weight, labels, advantages, old_logprobs, objective="cispo", eps_high=4.0, beta=0.0, temperature=1.0, chunk_tokens=2048, ) ``` Evaluation is the eleventh and only other entry: `align.eval.run(model, tasks=["minif2f"], n=32, verifier=...)`, which wraps `eval/harness.py::run_eval`. CLI: `python -m align grpo configs/p4_cispo_lean.toml --smoke` calls `recipes.grpo.run(...)`, so the existing torchrun command lines in the docs keep their shape. What is deliberately **not** public: `Trainer` internals, `TrainModule.optim_step`, shard layouts, `_`-prefixed helpers. The lazy `align/__init__.py` pattern is kept (`import align` must not import torch; `tests/test_public_api.py` pins it). --- ## 4. Migration plan ### 4.1 The freeze closure The task rule is: live GPU jobs run about two days; do not edit `scripts/p2/*`, `extras/vllm_rollout/*`, `align/verify/lean/*`, `align/utils/guard.py`, `align/train/trainer.py`. The P2 chain also launches new Python processes every round, and each one imports `align.*` fresh. A half-written or moved module breaks the next round even if the file itself is not on the list. So I computed the static transitive import closure of the P2 entry points (`scripts/p2/*.py`, `extras/vllm_rollout/*.py`, `scripts/sft.py`, `scripts/qwen3_export_check.py`, 20 files), following every `import` including lazy ones and every parent package `__init__` [read, AST script run for this document]. Result for those entry points alone: 101 of 129 `align/` modules. It is a conservative over-approximation (a lazy import on a path a P2 round never executes still counts), which is the safe direction. **[A2] The first draft stopped there, but the chain scripts launch more than the P2 entry points.** `.tmp/p2/r1/chain2_gpu_validation.sh` and `chain_after_baseline.sh` run `tests/test_tp_bench.py`, `tests/test_moe_ep_bench.py`, `scripts/olmoe_sft_smoke.py` and `scripts/ckpt_convert.py` as `torchrun` steps. Adding those four entry points pulls in four more modules (`model/tp.py`, `rollout/engine.py`, `rollout/paged.py`, `rollout/tp_engine.py`). Re-running the same AST closure gives **105 of 129 reachable, 24 free**. This matters because M6 edits `rollout/paged.py` and M6/M7 both touch the TP engine. Rule: the entry-point list of the closure is every `python`/`torchrun` target named in any `.tmp/p2/r1/chain*.sh` that is still pending, and the closure test (`test_frozen_surface.py`) takes that list from a checked-in manifest, not from this document. The 24 modules **not** in the closure, which may be edited or moved during the freeze: ``` align/data: pretrain_corpus.py stageb_sources.py align/inference: receipts.py align/kernels: _fa2_reference_upload.py fused_dpo.py ops.py align/model: gates.py weight_average.py align/ppo: stage_config.py align/rl: assembly.py batch_source.py format_instrument.py multiturn_source.py stage_config.py align/rl/envs: __init__.py lean_multiturn.py align/rollout: client.py native.py align/train: dpo_loss.py dpo_stage.py ref_cache.py align/utils: host_state_gate.py supervisor.py align/verify/lean: sandbox.py ``` The names the frozen scripts and files import today (the contract the shims must keep) [read, AST]: ``` align.config: load_config align.registry: render_banner align.data: hub; .ingest_parquet: iter_parquet_rows; .loader: collate_sft; .store: ShardDataset, ShardWriter align.eval: proof_stats; .verifier: Outcome align.exit_codes: PreflightRefused, run_with_exit_codes align.model: qwen3, qwen3_checkpoint; .checkpoint: load_hf_safetensors align.model.qwen3_checkpoint: align_to_hf, hf_to_align, read_qwen3_config, save_hf_qwen3 align.rollout.protocol: RolloutResult, read_request, spool_dirs, touch_heartbeat, write_result align.train.qwen3_stage: build_qwen3_stage_model, qwen3_entry_banner, qwen3_trainer_extras align.train.sft_loss: make_sft_loss_adapter align.train.trainer: Trainer align.train.stage_gates: GateLedger, assemble_sft_banner, assert_build_is_current, assert_distinct_devices, assert_probe_resolved, eval_source_hash, evaluate_and_gate, parse_args, record_golden_from_report, require_golden_receipt, run_entry_gates align.train.stage_smoke: build_stage_model, build_training_sampler align.utils: guard; .dist: barrier, init_distributed; .guard: apply_resource_guards, preflight_host_check align.verify.lean: ReplPool, StrictConfig, StrictVerifier, find_toolchain, strict; .repl_pool: ReplError, ReplLimits, ReplProtocolError; .strict: AUDIT_PRELUDE, REF_NAME, lex, parse_statement ``` Names added by the four chain-launched entry points **[A2]**: ``` align.model: tp, moe, moe_ep, olmoe, olmoe_checkpoint; .qwen3: Qwen3Config align.rollout.engine: PagedEngineConfig, SamplingParams align.rollout.paged: kv_bytes_per_token, plan_capacity align.rollout.tp_engine: TPPagedEngine align.train: fsdp, sharded_ckpt; .ckpt_format: OlmoeFormat; .moe_module: build_moe_train_module; .offload_adamw: make_optimizer align.train.trainer: MANDATORY_METRICS, Trainer, checkpoint_problem align.utils.dist: local_rank, rank, seed_everything, world_size; .guard: assert_paths_off_root, host_state; .tracking: Tracker align.data.store: ShardDataset align.tokenizer: Tokenizer align.model.olmoe_checkpoint: read_olmoe_config ``` `tests/test_frozen_surface.py` (M0) re-derives this list with the same AST scan on every run and asserts each name resolves, so a refactor that drops one fails in CI rather than in the middle of round 3. **[A2]** The scan's entry-point list is a checked-in manifest (`tests/frozen_entrypoints.txt`) that must contain every Python target named in a pending `.tmp/p2/r1/chain*.sh`. ### 4.2 Shim mechanics (verified) For a file that moves, the old path becomes: ```python # align/rl/grpo_loss.py (after the move) import sys from align.algorithms.grpo import loss as _m sys.modules[__name__] = _m ``` Checked with a throw-away package outside the repo [cpu-probe, deleted afterwards]: after `import old.impl`, `old.impl is new.impl` is `True`; `from old.impl import _private_name, SomeClass` returns the same objects; `from old import impl` returns the same module; `unittest.mock.patch("old.impl.f", ...)` changes the behaviour seen through the new path. So `isinstance` checks, private imports and monkeypatches in the distinct `align.*` import paths the tests use all keep working. (No test uses `mock.patch("align...")`, `monkeypatch.setattr("align...")` or `import_module("align...")` on a moved path today; 0 hits. **[A2]** re-checked by grep.) Pickle compatibility is not a concern: align checkpoints use safetensors and `weights_only=True`. **[A2] What the identity shim does NOT preserve (found by a second probe, same method).** 1. **`python -m old.path` silently does nothing and exits 0.** The runner imports the shim as `__main__`; the shim replaces `sys.modules["__main__"]` with the real module and the real module's `if __name__ == "__main__":` block never runs (its `__name__` is the new dotted path). Probe: `python -m pk.old.gate` printed nothing and exited 0, `python -m pk.new.gate` printed `REAL MAIN RAN` and exited 3. `align/utils/host_state_gate.py:31` documents `python -m align.utils.host_state_gate` and that module is the D-state safety gate (it must return `EXIT_PREFLIGHT_REFUSED` when the host is wedged); a silent exit 0 would turn the gate into a pass. **Rule 3:** a moved module that has an `if __name__ == "__main__"` block gets a shim that forwards it: `if __name__ == "__main__": raise SystemExit(_m.main())`. The manifest records which moved modules have a `main`, and `test_legacy_shims.py` runs `python -m ` in a subprocess for each and compares the exit code with `python -m `. Today only `host_state_gate.py` among the moved modules has such a block (`model/weight_average.py` has a documented CLI but does not move). 2. **Tests that read a module's source text now read the shim.** `tests/test_dpo_loss.py:190` greps `align/train/dpo_loss.py` for `from_pretrained`, `load_state_dict`, `AutoModel` and `NmoMistralForCausalLM`; `tests/test_h2_review_items.py:675-679` parses `align/utils/host_state_gate.py` for non-stdlib imports and reads `align/utils/guard.py`. After the shim they still pass, vacuously, because a 3-line shim contains none of those strings. See 4.6. 3. **Partial-initialisation cycles.** `ppo/__init__.py` and `rl/__init__.py` import their submodules eagerly. An old-path shim executes `from align.algorithms... import ...`, which runs `algorithms/__init__` and `algorithms//__init__`; if any of those import `align.rl` / `align.ppo`, the import order decides whether it works. The layering test cannot see this (shims are exempt). `test_legacy_shims.py` therefore imports every old and every new path in a **fresh interpreter, new path first and old path first**, and asserts both orders succeed. Rules 1 and 2: shims warn only under `ALIGN_WARN_LEGACY_IMPORTS=1` (a default warning would add stderr noise to every P2 round); and `tests/test_legacy_shims.py` asserts, from a manifest, that `import_module(old) is import_module(new)` for every moved path. For an `F` file the direction is reversed first: the new module is a facade that imports from the old one. When the chain exits (M7) the real code moves and the old path becomes the shim above. The shim stays for at least one release because `scripts/p2/*` and `extras/` import the old paths and are not ours to edit. ### 4.5 [A2] `eval_source_hash`: moving anything in or out of `align/eval/` invalidates every gate ledger `train/stage_gates.py::eval_source_hash` (`:94-113`) hashes the relative path and bytes of every `.py` under `align/eval/`. A `GateLedger` records that hash; on load, if the hash differs, `_load` deletes every gate in `EVAL_DEPENDENT_GATES` (the golden regression among them) and `require_golden_receipt` then refuses to train (`stage_gates.py:739-753`). Three planned moves change the hash: `eval/verifier.py` becoming a shim, `data/outcome_agreement.py` arriving in `eval/`, and the M8 path-hygiene edit of `eval/golden.py`. The first draft treated these as free once the chain had exited. They are not: every run directory that is resumed after M7 needs the golden regression re-run (a 7B checkpoint job; `scripts/eval.py --mode=golden`). Plan: (a) none of the three edits happens before the P2 chain exits (already true for M7, now stated for M8); (b) M7 and M8 land as **one hash epoch**, i.e. adjacent commits with a single golden re-run after the second; (c) `tests/test_layering.py` and `test_legacy_shims.py` print the new `eval_source_hash` so the epoch boundary is visible in CI; (d) the alternative that avoids the epoch keeps `align/eval/` byte-identical: leave `Outcome`/`OutcomeVerifier` in `eval/verifier.py`; cut `verify -> eval` (`verify/lean/reward.py:116`) by typing the result against a small Protocol in `verify/`; and put `outcome_agreement.py` in `recipes/` (L5, which may import `eval`) instead of `eval/`, with `align.data.__getattr__` serving the old name lazily. It costs a slightly less tidy layout and saves a 7B golden re-run, so it is the cheaper choice unless the owner wants the tidier layout. The owner decides (open question 11). ### 4.6 [A2] Tests that pin source text at old paths Reading file text of a library module or of `scripts/*.py` is an established pattern here (the CPU suite cannot execute the GPU scripts, so it pins them by structure). Found by grep: `test_dpo_resume_wiring.py` (string-matches `scripts/dpo.py`: `'"--resume"'`, `os.environ.get("ALIGN_RESUME") == "1"`, the rotation guard), `test_grpo_resume.py:293` (AST of `scripts/grpo.py`: `resume_from_latest` call ordering), `test_dpo_stage.py:1566` (`assert_launch_topology(cfg)` before `run_entry_gates(`), `test_ref_cache.py:179,300` (`scripts/dpo.py`, `scripts/precompute_ref_logprobs.py`), `test_h2_review_items.py:374,383` (`train()` in `scripts/sft.py`, `pretrain.py`), `test_qwen3_stage.py:301`, `test_g0_integration.py:38`, `test_olmoe_scripts.py` (several), `test_verifier.py:371` (`scripts/eval.py`), plus the library-text pins in 4.2 item 2. Consequences for the plan: (1) **M7 "make `scripts/*` 30-line wrappers" cannot keep the baseline green** unless each pin is retargeted to the `recipes/` file that now holds the flow, with the same assertion, in the same commit. M2's recipes are copies, so the pins should be written against both files from M2 on (a parametrised `[script, recipe]` pin), and the script pin is dropped only at M7. (2) M3's move of `train/dpo_loss.py` (and `utils/host_state_gate.py`) must retarget the two library-text pins to the new path in the same commit, and the shim manifest records the pin so `test_legacy_shims.py` fails if a pinned path is a shim (a vacuous pin is a lost guard, not a pass). (3) The gate "same passed counts" cannot hold literally once contract tests are added; it is restated in 4.3 as "every baseline test id still passes". ### 4.3 Steps Every step has the same gate: `pytest -m "not gpu and not oracle and not slow"` where **every test id in the M0 junit baseline still passes or is skipped for the same reason** (**[A2]** not "equal counts": M1 onward adds contract tests, so the totals must grow, while a baseline id going missing or silently skipping must fail), `tests/test_dependency_firewall.py` green, `tests/test_public_api.py` green (import stays cheap), `tests/test_frozen_surface.py` green, and, from M3, `tests/test_legacy_shims.py` (identity, `-m` forwarding, both import orders, no vacuous source pin: 4.2 and 4.6). All GPU-free. All run with `CUDA_VISIBLE_DEVICES=`. **[A2]** The suite shares a 48-core box with the live chain: run it under `nice -n 10`, `OMP_NUM_THREADS=4`, `-p no:cacheprovider`, and only after the host preflight in `/apps/CLAUDE.md` (`free -g; uptime`) passes; `conftest.py` already keeps `tmp_path` off `/tmp`. | Step | What | Effort | Touches `F` files? | Extra gate | |---|---|---|---|---| | **M0** | Safety net, no code moves: record the real CPU baseline (junit file); add `test_frozen_surface.py`, `test_legacy_shims.py` (empty manifest), `test_layering.py` (report-only, lists today's violations from 2.1) | 0.5 d | no | baseline numbers committed in the doc | | **M1** | Facade layer, additive: `protocols.py`, `batch.py` (re-exporting the existing batch classes), `algorithms/{base,registry}.py` and `algorithms/{sft,dpo,ppo,grpo,cispo,dapo}.py` as classes that wrap the existing loss adapters and `TrainModule` subclasses; contract test suite 2.3 | 1.5 d | no (imports only) | contract tests pass for all six | | **M2** | `recipes/` and `cli.py`: the `main()` flows of `scripts/{sft,dpo,grpo,ppo}.py` as functions, written as new files (the scripts are not edited). `align.load_run` splits `[algo]/[env]/[rollout]` before `load_config`. | 3 d | no | tiny-model parity: `recipes.sft` and `scripts/sft.py --smoke` produce identical `history.jsonl` losses on CPU/gloo (`train/stage_smoke.py` already builds the model); same for grpo and dpo smoke | | **M3** | Move the non-`F` modules with identity shims: `dpo_*`, `ref_cache`, `rl/{stage_config,batch_source,assembly,format_instrument,multiturn_source}`, `ppo/stage_config`, `utils/{host_state_gate,supervisor}`. Fix cycles that need only free files. **[A2]** Retarget the two library-text pins (`test_dpo_loss.py:190`, `test_h2_review_items.py:675`) in the same commit; `host_state_gate` gets the `__main__`-forwarding shim | 2 d | no | `test_legacy_shims.py` manifest grows by exactly the moved paths; `python -m align.utils.host_state_gate` exit code equals the pre-move exit code | | **M4** | `envs/`: `Env` protocol, `MathEnv`, `LeanEnv` from `rl/envs/lean_multiturn.py`; vendor `lean_prompts.py` as `envs/lean/prompts.py` with a sha256 identity test against `extras/vllm_rollout/lean_prompts.py`; extract `displayed_statement_matches`; collapse `MultiTurnBatchSource` into `RolloutSource(env=...)` | 3 d | no | `tests/test_lean_multiturn_env.py`, `test_multiturn_source.py`, `test_lean_gen_contract.py` unchanged and green; GRPO smoke bit-equal before and after | | **M5** | Kernel A/B: `kernels/fused_policy_loss.py` as a chunked `autograd.Function` (5.4-A) with an eager fp64 oracle test; `mm(out_dtype=float32)` probe on GPU after the freeze | 3 d CPU + 0.5 d GPU | no | fp64 gradient oracle (max abs error recorded); GPU parity vs `fp32_head_logprobs` | | **M6** | Kernel C: Triton paged-attention decode (5.4-C) behind `TritonPagedAttention`, `fast` and `invariant` modes | 8 d (CPU interpreter tests 3 d; GPU validation and tuning 5 d) | **[A2] yes: `rollout/paged.py` is in the closure of `tests/test_tp_bench.py`; starts only after the chain exits** | bit-invariance test: row output independent of batch composition; `tests/test_paged_engine*.py` green; decode benchmark vs `TorchPagedAttention` | | **M7** | After the P2 chain has exited (gate **[A2]**: `pgrep -f '\.tmp/p2/r1/chain'` and `pgrep -f 'scripts/p2/'` both empty, not only `chain_after_baseline`: `chain4.sh` and `chain5.sh` are live today; `nvidia-smi` idle, as in `docs/P3_ENGINE.md` GPU plan): move the `F` files, flip each facade to a shim, rewrite the three legacy `__init__`s as lazy re-exports, add the new lazy names to `align/__init__.py`, retarget the script-text pins (4.6) and make `scripts/*` 30-line wrappers, sweep path mentions in `docs/` (README, `ARCHITECTURE.md`, `DEPENDENCY_POLICY.md` and 6 more name `align/rl/`, `align/ppo/`, `align/utils/`, `align/train/...`) | **3 d** (was 1.5: about 55 `F` modules, ten-odd pins, three `__init__`s, docs) | yes | full suite and frozen-surface test after **each** moved file, not at the end; then one golden re-run (4.5) | | **M8** | Open-source hygiene: remove the hard-coded paths (2.1), `Nmo*` names (aliases kept; 38 files mention `nmo`/`NMO`), accept both `ALIGN_*` and `NMO_*` env vars, add a LICENSE (none exists in the repo root), relax `torch==2.11.0` / `triton==3.6.0` / `requires-python <3.13` pins to tested ranges, CI lane with ruff + firewall + layering, README rewritten around the ten call sites | 3 d | yes | clean checkout test: `uv venv && uv pip install -e .[dev]` and the CPU suite pass with no `/apps` path present | Total about 27.5 working days (about 5.5 weeks for one engineer; **[A2]** M7 re-estimated from 1.5 to 3 d). M1 to M5 are all runnable during the freeze because they only add files or move non-`F` ones. M6 (it edits the frozen `rollout/paged.py`) and the GPU halves of M5 wait for free GPUs. ### 4.4 Risks | Risk | Mitigation | |---|---| | A new file in an existing package changes import order or a `__init__` side effect | Only new subpackages are added during the freeze; no existing `__init__.py` is edited until M7 | | The facade and the old class drift while both exist | Facades hold no logic; contract tests call both paths and compare | | `sys.modules` shim hides a circular import | `test_layering.py` is strict before M7 flips anything | | M2 recipes diverge from scripts that P2 still runs | Parity test on tiny model; the scripts are the source of truth until M7; the source pins of 4.6 are parametrised over `[script, recipe]` from M2 | | Test counts in docs are stale, so "stay green" has no baseline | M0 records it | | ~~Moving `train/stage_gates.py`~~ **[A2]** | No longer moved (2.1). `eval/` changes still invalidate the ledger, see 4.5 | | **[A2]** `python -m ` through a `sys.modules` shim silently does nothing and exits 0 | `host_state_gate.py` is a safety gate with a documented `python -m align.utils.host_state_gate`. Shim rule 3 in 4.2 plus a subprocess test | | **[A2]** Source-pinning tests read old file paths and script text (4.6) | Retarget each pin in the same commit as the move, and fail the gate if a pin goes vacuous | | **[A2]** Eval source hash epoch (4.5) | Plan one golden re-run in M7 | --- ## 5. Where custom Triton kernels pay off ### 5.1 Method and constants For each candidate the question is: **is the op bound by bytes or by FLOPs, and how much of the iteration is it?** A kernel can only recover the gap between what the code does today and the roofline. Constants: | Constant | Value | Source | |---|---|---| | A10G HBM bandwidth | 600 GB/s | **[assumed]** datasheet, same value used in `docs/P3_ENGINE.md` | | bf16 tensor-core peak / best measured | about 70 / 68 TFLOP/s (one `8192x4096 @ 4096x4096`); 44 to 53 TFLOP/s in-layer | [read] `docs/MEASUREMENTS.md:102-105,254` | | fp32 CUDA-core peak, sustained | 31.2 peak, 25 sustained (sensitivity 20 to 31) | **[assumed]** datasheet; not measured here. TF32 is disabled in `rl/cispo.py::_strict_fp32_matmul` | | Qwen3-8B (Goedel-Prover-V2-8B) | hidden 4096, 36 layers, 32 q heads / 8 kv heads, head_dim 128, vocab 151,936, intermediate 12,288, untied head | [read] model `config.json` | | KV bytes per token | 2 x 36 x 8 x 128 x 2 B = 147,456 B (144 KiB) | [calc]; matches `docs/P3_ENGINE.md` (3.60 GiB / 26,192 tokens) | | Weights bf16 | 15.26 GiB = 16.39 GB; 27.3 ms to read once at 600 GB/s | [calc]; 15.26 GiB is `docs/P3_ENGINE.md` | | Eager launch cost | 6 us per op | **[assumed]**, typical 5 to 10 us | ### 5.2 Where the time goes in one RL iteration (why decode dominates) Illustration with stated knobs, not a measurement. 16 prompts x 8 samples, mean completion 4,096 tokens (cap 16,384): 524k generated tokens per iteration. - Generation at vLLM-class throughput (about 876 tok/s end to end, the vLLM figure quoted in `docs/P3_ENGINE.md`): about 600 s. At the pure-torch engine's modelled tp=4 rate (218 to 386 tok/s, rows 3 to 5 of the table in 5.4-C; **[A2]** the first draft's low end, 100 tok/s, came from a tp=1 batch of 16 that does not fit in memory, see 5.4-C): 1,360 to 2,400 s. - Training on the same tokens, LoRA with activation checkpointing: body forward `2 x 6.95e9 x tokens` (the 36 blocks hold 6.95 B parameters [calc]); with recompute and a dX-only backward, about 3x forward = 41.7 GFLOP/token; 524k tokens = 21.8 PFLOP; on 3 train GPUs at 45 TFLOP/s with no comm: about 160 s. FSDP over host shared memory is comm-bound on this box (`docs/MEASUREMENTS.md` section (h), 92% of a micro-step), so 160 s is a floor. Reading: with the native engine, generation is about 8x to 15x the training body compute (the 160 s above excludes the head, which adds about 35 s on 3 GPUs: 32 micro-batches x 3.3 s / 3, see 5.4-A); even against vLLM it is the larger term. The decode path is where a kernel changes the iteration time; the head is a second-order term. This ordering drives 5.5. ### 5.3 What exists - Triton: `kernels/flash_attention.py` (FA2 forward and backward, adapted from the user's `Flash-Attention-101/triton/flash_attention.py`, kept as `_fa2_reference_upload.py`), `kernels/ops.py` (RMSNorm/RoPE/SwiGLU, pedagogy, off by default). The FA2 backend is not selectable at HEAD: no gate artifact at the gate path (REFERENCE_GAP_ANALYSIS 1.1 item 9); SDPA is the working backend. - **Not Triton, despite the name:** `kernels/fused_ce.py` and `kernels/fused_dpo.py` are chunked `torch.autograd.Function`s. They implement the Liger FusedLinearCrossEntropy design (arXiv:2410.10989, "FusedLinearCrossEntropy" paragraph, Eq. 15-17: chunk the head, write `dh = W dlogits` and `dW += h dlogits^T` during forward) in torch matmuls. - `rollout/paged.py::TritonPagedAttention` is an interface with `raise NotImplementedError`. Its docstring already states the kernel contract, including bit-invariance ("fixed KV-split count per context length, no atomics"). ### 5.4 Candidates #### A. Fused head + policy-gradient token loss (with logprob gather) **Today** (`rl/cispo.py::fp32_head_logprobs`, `:202`): per chunk of 2,048 tokens, `h.float() @ W32.T` in strict fp32, `logsumexp`, `gather`; `torch.utils.checkpoint` per chunk, so backward recomputes the GEMM; a separate no-grad pass for entropy. **GEMM count** at `2*H*V = 1.245 GFLOP/token` (20.4 TFLOP per 16,384-token micro-batch [calc]), frozen head (LoRA): | Path | GEMMs | Arithmetic | Time per 16k tokens | |---|---|---|---| | Today: fwd + checkpoint recompute + `dh` + entropy pass | 4, strict fp32 | 4 x 20.4 / 25 | **3.3 s** (2.6 to 4.1 for 31 to 20 TFLOP/s) | | Fused: fwd with `dlogits` formed in-chunk, then `dh = dlogits @ W`; entropy from the same sweep | 2, bf16 operands, fp32 output | 2 x 20.4 / 50 | **0.82 s** (0.68 to 0.91 for 60 to 45) | Ratio about 4x [calc, with the assumed fp32 rate]. If the head is trainable (full FT), add one `dW` GEMM to each side (5 vs 3): the ratio is the same. **Why the in-chunk form works for this loss.** For CISPO the per-token gradient **of the objective J** with respect to the logit vector is `c_t (softmax(z) - onehot(y))` with `c_t = -A_t * sg(clip(r_t, ., 1+eps_high))` (CISPO Eq. 4-5, arXiv:2506.13585 Sec. 3.1; the weight is a stop-gradient constant). **[A2]** The sign is that of the ascent direction: the loss `-J` that the optimizer minimises has `c_t = +A_t * sg(clip(r_t, ., 1+eps_high))`. Checked in fp64 autograd on a 11-way softmax: both signs reproduce the autograd gradient to 7e-18, and the two differ only by the overall sign. The kernel must use the loss sign. `r_t` needs the new logprob, which the same chunk has just computed, so `c_t` is known before `dlogits` is formed. The KL term adds a closed-form function of the same logprob. This is exactly the structure of `fused_ce._FusedLinearCrossEntropy` (gradient written in forward, backward is a scalar rescale), extended with the token coefficient `c_t`. For the clipped-surrogate objectives (DAPO/A3) `c_t = -A_t r_t 1[not clipped]`, also known in-chunk. **The precision insight (the real reason the fp32 head matters).** MiniMax (arXiv:2506.13585 Sec. 3.2) attribute the train/inference probability mismatch to "high-magnitude activations in the LM head" and fix it with an FP32 head (correlation about 0.987 to 0.997, their Fig. 3). In align the head weights and the final hidden state are bf16. A bf16 product is exact in fp32 (8+8 mantissa bits < 24), and both a bf16 tensor-core GEMM and an fp32 GEMM accumulate in fp32. So an "fp32 head" built from bf16 operands differs from a bf16 GEMM **only in whether the output logits are rounded to bf16**. At `|z| ~ 16..32` the bf16 spacing is 0.125, so a logit is off by up to 0.06 and so is a logprob. [cpu-probe] N=512, H=1024, V=16,384, random bf16 `h` and `W` with logits up to `|z| = 50.6`; fp64 reference of the same bf16 operands. fp32-head max abs logprob error **7.1e-6**; bf16-output matmul max abs logprob error **0.145** (mean 0.049). That is the whole effect, reproduced on CPU, with operands identical in both arms. Consequence: the head does not need fp32 *compute*; it needs fp32 *output*. `torch.mm(a_bf16, b_bf16, out_dtype=torch.float32)` exists in torch 2.11 (the op `aten::mm.dtype` is registered; on CPU it reports "could not run on the CPU backend", so it is CUDA-only and **unverified on a GPU here**). The current `fused_ce.py:` line `logits = torch.matmul(h_chunk, weight.t()).to(cdtype)` rounds to bf16 first and upcasts after, so the SFT loss carries this rounding noise too (harmless for SFT gradients, which are unbiased in aggregate; it matters for ratios). The same trick makes the P4 fp32 head about 3x to 4x cheaper than strict-fp32 and also saves the fp32 weight copy (`weight.float()` is 2.49 GB, against 1.25 GB bf16 [calc]) on a 23 GB card. Caveat to test on GPU: tensor-core accumulation is not IEEE fp32 (reduced alignment and truncation inside the MMA). The size of that error against bf16 output rounding must be measured with the probe above run on CUDA. **[A2] The backward GEMM is not "free precision" either.** The second fused GEMM, `dh = dlogits @ W`, only runs at the bf16 tensor-core rate if `dlogits` is cast to bf16 first (a strict-fp32 `dlogits @ W` is exactly the cost being removed). The forward logprob keeps the 7e-6 accuracy above, but `dh` does not. [cpu-probe] N=512, H=1024, V=16,384, random bf16 `h`, `W`, token coefficients `c_t ~ N(0,1)`: relative L2 error of `dh` against fp64 is **3e-7** with an fp32 `dlogits` and **1.8e-3** with a bf16-rounded `dlogits` (max abs 1.8e-3 against `max|dh|` 0.68). That is ordinary bf16 backward noise, but it means the fused path is not bit-comparable with the strict-fp32 path, the M5 oracle test needs a tolerance of about 5e-3 relative on `dh` (and 1e-4 on logprobs), and "fp32 head" in the P4 pre-registration should be read as "fp32 logits", not "fp32 gradient". The cast adds one read and one write of the tile (about 1.9 GB per 2,048-row chunk, 25 ms per 16k tokens), already inside the 10% to 16% range below. **Share of a training step.** Body about 3 x 228 TFLOP at 45 TFLOP/s is about 15 s per 16,384-token micro-batch [calc]; head 3.3 s today versus 0.82 s fused: a saving of 2.5 s of about 18.3 s, **about 13% of the training half of an RL iteration**, and about **[A2]** 1% to 3% of the iteration end to end (32 micro-batches x 2.5 s / 3 GPUs = 27 s against an iteration of about 1,550 to 2,600 s with the native engine, about 800 s against vLLM; 5.2). It is worth doing because it is cheap and also removes 1.2 GB of resident memory, not because it moves the iteration. **Is Triton needed?** No. Elementwise traffic per 2,048-row chunk: fp32 logits are 1.245 GB; today about 8 passes (GEMM write, `logsumexp` read, recompute write and read, softmax write and read, `dlogits` read) is 10 GB, or 16.6 ms at 600 GB/s, 133 ms per 16k tokens [calc]. Against today's 3.3 s that is 4%. **[A2] The denominator that matters for the Triton decision is the fused path's, not today's.** After A the head costs 0.82 s, and its elementwise tail is about 7 passes (logits write, `logsumexp` read, softmax read, `dlogits` write, bf16 cast read and write, `dh` GEMM read, about 7.5 GB per chunk, 100 ms per 16k tokens): **10% to 16% of the fused head**, 0.5% to 0.7% of the 18.3 s training step. A 3x faster fused epilogue (the Liger figure below) would recover about two thirds of that, at most 0.07 s per micro-batch. The conclusion (do not write Triton for it) stands, but at the stated 4% the reasoning understated the residual by 3x. Fusing it (Liger reports a 3x faster *cross-entropy kernel* in isolation, arXiv:2410.10989 Fig. 2a and 3a, at vocab 163,840, benchmarking the kernel alone with the GEMM excluded) would recover at most about 0.1 s per micro-batch. AgileRL's own note that its Liger GRPO kernel gives no speedup (1.2) is the same fact. Ship it as a chunked `autograd.Function` (`kernels/fused_policy_loss.py`), tested against `fp32_head_logprobs` and an fp64 oracle. #### B. fp32-head `logsumexp` as a standalone kernel Does not pay. After A it is an epilogue over a `[chunk, V]` fp32 tile, about 10% to 16% of the fused head's time (4% of today's), and a Triton GEMM on sm_86 (99 KB shared memory, `docs/MEASUREMENTS.md:124`) is unlikely to beat cuBLAS on these shapes. The same document reached the same conclusion for the grouped-GEMM kernel (P1-12 fails its own entry condition). The only reason to write it later is to avoid materialising the `[chunk, V]` tile at all (memory, not time), and chunk size already bounds that: at 512 rows the tile is 311 MB. #### C. Paged-attention decode (the one that pays) **Today** (`rollout/paged.py::TorchPagedAttention`, `'segment'` mode, read above): for every decode segment, every layer: `cache.gather` copies K and V into a contiguous strip (read L + write L), a second copy into a zero-filled padded buffer (zero fill `L_max`, copy read L + write L), then one SDPA over `L_max` with a boolean mask. Per sequence per layer per tensor (K or V) the traffic is about `4L + 2 L_max`, against `L` for a fused kernel that reads blocks once through the block table. With `rho = L_max / L_mean`, the multiplier is `4 + 2 rho`: 8x at `rho = 2`. The length distribution of a prover RL batch is heavy-tailed (most stop in a few thousand tokens, a few reach the 16k to 40k cap, `rollout/paged.py` docstring), so `rho >= 2` is the normal case, and `docs/P3_ENGINE.md` already flags the padded transient (1.17 GiB per layer call at 64 sequences, 4.7 GiB if SDPA falls back to the math backend with GQA expansion). On top of bytes there is launch overhead: about 12 eager ops per segment per layer is `B x 36 x 12` launches per step (27,648 at B = 64), about 166 ms at 6 us [assumed]; the GPU is idle while Python issues them. Model (all [calc] from 5.1; `tp` as in `docs/P3_ENGINE.md`; comm term is that doc's assumed 20 to 26 ms for B = 64 at tp = 4 and about 10 ms for B = 16; fused kernel at 85% of HBM peak [assumed]; step = max(GPU time, CPU launch time) for today): | Setting | KV tokens (capacity) | Weights ms | KV ideal ms | Today step ms (tok/s) | Fused step ms (tok/s) | Speedup | |---|---|---|---|---|---|---| | tp=1, B=6, ctx 4,096, rho=2 | 24,576 (26,192) | 27.3 | 6.0 | 76 (79) | 34 (174) | 2.2x | | tp=4, B=16, ctx 4,096, rho=2 | 65,536 (438,048) | 6.8 | 4.0 | 49 (326) | 22 (743) | 2.3x | | tp=4, B=64, ctx 4,096, rho=2 | 262,144 (438,048) | 6.8 | 16.1 | 166 (386) | 52 (1,240) | 3.2x | | tp=4, B=48, ctx 8,192, rho=2 | 393,216 (438,048) | 6.8 | 24.2 | 220 (218) | 55 (870) | 4.0x | **[A2] Rows removed from the first draft because they do not fit in the card.** The first draft listed tp=1 B=16 at ctx 4,096 and 8,192 (3.4x and 4.4x) and tp=4 B=64 at ctx 8,192 (4.2x). The KV pool the native engine can allocate is in `docs/P3_ENGINE.md` ("Native engine, bf16 KV" table): 26,192 tokens at tp=1 (weights 15.26 GiB leave 3.60 GiB), 163,472 at tp=2, 438,048 at tp=4 (util 0.90). tp=1 B=16 at ctx 4,096 needs 65,536 tokens (2.5x over) and at 8,192 needs 131,072 (5x over); tp=4 B=64 at 8,192 needs 524,288 (1.2x over). Those speedups were computed correctly for the stated inputs but describe a batch the engine cannot hold, and the 3.4x to 4.4x figures were the top of the quoted range. Replacement rows use the largest batch that fits (B=6 at tp=1, B=48 at tp=4 with ctx 8,192; 20 ms comm for B=48). Row arithmetic, checked: KV ideal = tokens x 147,456 B / tp / 600 GB/s; today = weights + (4+2rho) x KV ideal + comm (launch-bound at B=64: 27,648 x 6 us = 166 ms); fused = weights + KV ideal / 0.85 + comm. Expected: **2x to 4x on decode steps at the largest batch that fits (6 to 64) and rho about 2**; larger with a larger `rho` or longer context; at batch 1 the step is weight-bound and the kernel changes little (it still removes the launch overhead, 2.6 ms vs 27 ms). Amdahl: this speeds the generation half, which 5.2 shows is the larger term. The bar in `docs/P3_ENGINE.md` (native at least 0.5x vLLM throughput) is the acceptance test, and the stated fallback there ("the Triton kernel is the next step, not TP") is consistent with this analysis. The vLLM kernels in the PagedAttention paper (arXiv:2309.06180 Sec. 5.1, "Kernel-level Optimization") are three fusions: fused reshape-and-block-write ("to minimize kernel launch overheads"), fused block read and attention (adapted from the FasterTransformer attention kernel, reading the KV cache through the block table), and fused block copy. **Design constraint from RL, not performance:** the per-row key reduction must not depend on batch composition (the `invariant` mode of `TorchPagedAttention`, `rollout/paged.py`). Use a split-KV (flash-decoding style) kernel with a **fixed split size per context length** (e.g. 512 keys per split, so the split count is `ceil(L / 512)`, a function of `L` only), a second reduce kernel in a fixed order, no atomics. That keeps `rho` (the train/infer logprob ratio) unaffected by who shares the batch. It is the user's FA2 forward loop (`Flash-Attention-101/triton/flash_attention.py`, online softmax per KV block) with `Lq = 1`, the KV loop split across programs, and the block table as an extra indirection; the numbered-note house style applies (one note on split-KV online softmax merging, one on block-table indexing, each with a notebook that checks the kernel against the naive version). **Validation plan (needs free GPUs):** (1) bitwise: same sequence, run alone and inside batches of 2, 16, 64 with different neighbours: identical logits. (2) numeric: against `TorchPagedAttention(mode="row")` at bf16, max abs logit error stated. (3) throughput: `tests/test_paged_engine_bench.py --gpu` with the same prompt ids as the vLLM manifest. (4) peak memory with `--reserve-gib` reduced, since the padded transient goes away. #### D. Candidates that do not pay | Candidate | Estimate | Verdict | |---|---|---| | Per-token CISPO/GRPO loss arithmetic alone | tensors of `B x T` fp32: 64 KB at 16k tokens; about 10 elementwise ops is below 1 MB of traffic, about 50 us of launch time against a multi-second step | Do not write a kernel | | `adamw8bit` Triton step (open item in DEPENDENCY_POLICY) | ideal about 18 GB of traffic for 1.8 B local elements [calc, about 10 B/element] = 30 ms; runs once per optimizer step, and a step is 8 to 85 micro-steps of seconds each | Under 1% of a step. No | | Fused LoRA add (`W x + B A x`) | one saved read+write of each `[T, out]` bf16 activation per projection: 2.55 GB per layer at 16k tokens = 4.3 ms, 153 ms per forward over 36 layers [calc] | About 1% of a 15 s body pass; no | | FA2 Triton as default | Gate exists and is not enabled (no artifact at the gate path); attention is about 4% of layer FLOPs at seq 1k (`kernels/fused_ce.py` docstring), more at 16k | Finish the equivalence gate first; a measurement task, not a kernel task | | RMSNorm/RoPE/SwiGLU Triton (`kernels/ops.py`) | kept flag-gated and off; `torch.compile` fuses them | Keep as pedagogy | ### 5.5 Ranking 1. **C, paged-attention decode.** About 2x to 4x on decode (3x central), on the largest term. 8 days including GPU tuning. Needs free GPUs. 2. **A, fused head + policy loss as an `autograd.Function` with `out_dtype=float32`.** About 4x on the head, about 13% of the training half, 1.2 GB less resident memory, and it fixes the SFT output-rounding as a side effect. 3 days plus 0.5 day GPU. No Triton. 3. Everything else in 5.4-D: no. --- ## 6. Open questions for the owner 1. **Name.** `align` is a common PyPI name. An open-source release needs a distinct distribution name; the import name can stay. Renaming `Nmo*` (38 files) is mechanical; do you want the old names kept as aliases for one release? 2. **Licence.** The repo has no LICENSE. AgileRL is Apache-2.0, Liger-Kernel is BSD-2-Clause, open-instruct (vendored GRPO loss, `rl/grpo_loss.py`) is Apache-2.0: Apache-2.0 for align is compatible. Confirm. **[A2]** The vendored open-instruct code (`rl/__init__.py` says "vendored verbatim") also needs its upstream copyright notices kept in the files and a `NOTICE` entry before release; M8 does not list that, add it. 3. **Is `expert_iteration` an algorithm or a recipe for you?** The proposal makes it an `OuterLoop` with its own small interface (2.3). If you would rather keep it a script, drop it from the registry and keep only `recipes.sft` and `recipes.eval`. 4. **A10G constants.** Section 5 rests on 600 GB/s and a 25 TFLOP/s sustained fp32 rate, both assumed, plus 6 us eager launch cost. Three 2-minute microbenchmarks (a bandwidth copy, an fp32 and a bf16 GEMM at the head shape, an empty-op loop) on a free GPU would turn every [assumed] in 5.1 into a measurement. They are GPU jobs and must take the heavy-job lock. 5. **`mm(out_dtype=float32)` on CUDA.** Not runnable here. If it is absent or slow for the head shape the fallback is a Triton GEMM with fp32 epilogue, which moves A from 3 days to about 6 and changes the 5.5 ranking only slightly. 6. **Tensor-core accumulation error versus bf16 output rounding** for the head (5.4-A caveat): needs the CPU probe repeated on a GPU. 7. **Test layout.** AgileRL mirrors the package in `tests/`. This proposal keeps the 121 flat files and adds `tests/contract/`. Mirroring would change documented `pytest tests/test_x.py` commands in several docs; say if you want it. 8. **Which `[algo]` name for GSPO-style trajectory ratios.** Not implemented in align. Add it in M1 or leave it out until a run needs it? 9. **`inference/engine.py` (contiguous KV) versus `PagedEngine`.** After M6 the paged engine can serve eval too, and `inference/` could be deleted (962 + 202 + 220 lines). Not proposed now because eval receipts and golden regression depend on its exact numerics. 10. **Test baseline.** README says 1608 passed, DEPENDENCY_POLICY 1089, REFERENCE_GAP_ANALYSIS 1107. M0 resolves it; until then no "stays green" claim here has a number behind it. 11. **[A2] Eval hash epoch.** Moving `Outcome` into `verify/` and `outcome_agreement.py` into `eval/`, and the M8 edit of `eval/golden.py`, change `eval_source_hash` and invalidate every gate ledger, so each run dir resumed afterwards needs a golden re-run (4.5). Accept one re-run at M7/M8, or take the alternative that keeps `align/eval/` byte-identical? 12. **[A2] Chain manifest.** The freeze closure is only as complete as the list of entry points. Should the repo carry `tests/frozen_entrypoints.txt`, maintained by whoever writes a new `.tmp/p2/r1/chain*.sh`, or should the closure scan parse the chain scripts directly? 13. **[A2] `CodeEnv` binaries.** `prlimit` and whatever interpreter runs candidate code are external binaries; DEPENDENCY_POLICY requires a dated entry for those (as for Lean, 2026-10-12). Which languages should `CodeEnv` support first?