diff --git a/configs/ci/integration/reverse_text_rl_opd/start.toml b/configs/ci/integration/reverse_text_rl_opd/start.toml
index edd23df9c7..abd8c69b60 100644
--- a/configs/ci/integration/reverse_text_rl_opd/start.toml
+++ b/configs/ci/integration/reverse_text_rl_opd/start.toml
@@ -1,10 +1,10 @@
-# Smoke test for the RL-entrypoint OPD (on-policy distillation) training mode.
-# The student inference deployment is launched by the rl entrypoint on GPU 0;
-# the teacher inference server is started externally by the test fixture (see
+# Smoke test for the RL-entrypoint opd algorithm (on-policy distillation).
+# The policy inference deployment is launched by the rl entrypoint on GPU 0;
+# the frozen reference server is started externally by the test fixture (see
# tests/integration/test_reverse_text_rl_opd.py) on the same GPU. Trainer
# takes GPU 1. Training config mirrors `reverse_text/start.toml`.
-max_steps = 5
+max_steps = 20
seq_len = 2048
[model]
@@ -15,10 +15,16 @@ project = "reverse-text-ci"
name = "ci-rl-opd"
[orchestrator]
-training_mode = "opd"
batch_size = 128
group_size = 16
+[orchestrator.algo.advantage]
+type = "opd"
+
+[orchestrator.algo.teacher]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
+base_url = ["http://localhost:8001/v1"]
+
[orchestrator.renderer]
name = "qwen3"
@@ -38,12 +44,6 @@ max_completion_tokens = 128
[[orchestrator.eval.env]]
id = "reverse-text"
-[orchestrator.teacher.model]
-name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
-
-[orchestrator.teacher.client]
-base_url = ["http://localhost:8001/v1"]
-
[trainer.optim]
lr = 3e-6
diff --git a/configs/ci/integration/reverse_text_rl_sft/start.toml b/configs/ci/integration/reverse_text_rl_sft/start.toml
index 6b26bb3335..99b21f3348 100644
--- a/configs/ci/integration/reverse_text_rl_sft/start.toml
+++ b/configs/ci/integration/reverse_text_rl_sft/start.toml
@@ -1,10 +1,10 @@
-# Smoke test for the RL-entrypoint SFT (on-policy hard distillation) training
-# mode. The student inference deployment is launched by the rl entrypoint on
-# GPU 0; the teacher inference server is started externally by the test
-# fixture (see tests/integration/test_reverse_text_rl_sft.py) on the same GPU.
-# Trainer takes GPU 1. Training config mirrors `reverse_text/start.toml`.
+# Smoke test for the RL-entrypoint sft algorithm (hard distillation).
+# The policy inference deployment is launched by the rl entrypoint on GPU 0;
+# the frozen sampling server is started externally by the test fixture (see
+# tests/integration/test_reverse_text_rl_sft.py) on the same GPU. Trainer
+# takes GPU 1. Training config mirrors `reverse_text/start.toml`.
-max_steps = 5
+max_steps = 20
seq_len = 2048
[model]
@@ -15,10 +15,25 @@ project = "reverse-text-ci"
name = "ci-rl-sft"
[orchestrator]
-training_mode = "sft"
batch_size = 128
group_size = 16
+[orchestrator.algo.advantage]
+type = "sft"
+
+[orchestrator.algo.teacher]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
+base_url = ["http://localhost:8001/v1"]
+
+# The student's renderer tokenizes the teacher's messages into the student's
+# token space (backfill). Use `default` (wraps the student tokenizer's
+# apply_chat_template) so the CE target is faithful to the student's actual
+# chat template. The stock `qwen3` renderer reimplements the Qwen format and
+# injects an empty `` block, which this model's custom template
+# does not emit — corrupting the distillation target.
+[orchestrator.renderer]
+name = "default"
+
[orchestrator.train.sampling]
max_completion_tokens = 128
@@ -35,12 +50,6 @@ max_completion_tokens = 128
[[orchestrator.eval.env]]
id = "reverse-text"
-[orchestrator.teacher.model]
-name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
-
-[orchestrator.teacher.client]
-base_url = ["http://localhost:8001/v1"]
-
[trainer.optim]
lr = 3e-6
diff --git a/configs/debug/algorithms/README.md b/configs/debug/algorithms/README.md
new file mode 100644
index 0000000000..a670af2247
--- /dev/null
+++ b/configs/debug/algorithms/README.md
@@ -0,0 +1,65 @@
+# Algorithm — Debug Configs
+
+Minimal end-to-end configs for the algorithms against bundled verifiers envs, using `PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT` as the policy.
+
+| Config | Algorithm | Frozen model | Notes |
+|---|---|---|---|
+| `grpo.toml` | `grpo` | none | |
+| `max_rl.toml` | `max_rl` | none | GRPO with mean-normalized advantages (maximum-likelihood RL) |
+| `opd.toml` | `opd` | local vLLM (`Qwen3-0.6B-Reverse-Text-RL`) | |
+| `opd_lora.toml` | `opd` | local vLLM (`Qwen3-0.6B-Reverse-Text-RL`) | trains a LoRA adapter (rank 8) |
+| `sft_distill.toml` | `sft` | local vLLM (`Qwen3-0.6B-Reverse-Text-RL`) | |
+| `sft_distill_lora.toml` | `sft` | local vLLM (`Qwen3-0.6B-Reverse-Text-RL`) | trains a LoRA adapter (rank 8) |
+| `sft_distill_external.toml` | `sft` | PI inference (`openai/gpt-5-mini`) | external OAI endpoint; no local server |
+| `self_distill.toml` | `opsd` | none (`model = "policy"`) | SDFT against the live policy; demo from reverse-text's `answer` field |
+| `echo.toml` | `echo` | none | multi-turn `alphabet-sort`; CE on observation tokens |
+| `mixed_grpo_opd.toml` | `grpo` + `opd` (per env) | local vLLM (`Qwen3-0.6B-Reverse-Text-RL`) | two envs, one run; heterogeneous batches (with/without `ref_logprobs`) |
+
+The policy inference server is auto-launched on GPU 0 at `http://localhost:8000/v1` with `gpu_memory_utilization=0.5`. The local frozen model (used by `opd*.toml`, `sft_distill.toml` / `sft_distill_lora.toml`, and `mixed_grpo_opd.toml`) is **not** auto-launched — start it manually on GPU 1.
+
+Frozen models are declared inline on the algorithm — `[orchestrator.algo.teacher]` with `name` + `base_url` — and prime-rl never hosts them; only the trainable policy's server is managed by the `rl` entrypoint.
+
+## Start the local frozen model
+
+Needed for `opd*.toml`, `sft_distill.toml` / `sft_distill_lora.toml`, and `mixed_grpo_opd.toml`:
+
+```bash
+CUDA_VISIBLE_DEVICES=1 uv run inference \
+ --model.name PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL \
+ --server.port 8001 \
+ --gpu-memory-utilization 0.5 \
+ --model.enforce-eager
+```
+
+## Run the debug configs
+
+```bash
+# GRPO (no frozen model)
+uv run rl @ configs/debug/algorithms/grpo.toml
+
+# MaxRL (no frozen model)
+uv run rl @ configs/debug/algorithms/max_rl.toml
+
+# OPD (needs the frozen model on port 8001)
+uv run rl @ configs/debug/algorithms/opd.toml
+uv run rl @ configs/debug/algorithms/opd_lora.toml
+
+# SFT distillation (needs the frozen model on port 8001)
+uv run rl @ configs/debug/algorithms/sft_distill.toml
+uv run rl @ configs/debug/algorithms/sft_distill_lora.toml
+
+# SFT distillation from openai/gpt-5-mini via PI inference
+# (requires PRIME_API_KEY + PRIME_TEAM_ID in env; no local frozen model needed)
+uv run rl @ configs/debug/algorithms/sft_distill_external.toml
+
+# Self-distillation against the live policy (no frozen model)
+uv run rl @ configs/debug/algorithms/self_distill.toml
+
+# ECHO (no frozen model; multi-turn env)
+uv run rl @ configs/debug/algorithms/echo.toml
+
+# Mixed per-env algorithms: GRPO + OPD in one run (needs the frozen model on port 8001)
+uv run rl @ configs/debug/algorithms/mixed_grpo_opd.toml
+```
+
+See [docs/algorithms.md](../../../docs/algorithms.md) for what each algorithm does and how to compose custom ones.
diff --git a/configs/debug/algorithms/echo.toml b/configs/debug/algorithms/echo.toml
new file mode 100644
index 0000000000..59ffe15e44
--- /dev/null
+++ b/configs/debug/algorithms/echo.toml
@@ -0,0 +1,50 @@
+# ECHO on the multi-turn alphabet-sort env (bundled with verifiers): GRPO on
+# action tokens + weighted CE on the env's observation tokens.
+# uv run rl @ configs/debug/algorithms/echo.toml
+
+max_steps = 20
+seq_len = 4096
+
+[model]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
+
+[wandb]
+project = "algorithms-debug"
+name = "debug-echo"
+
+[orchestrator]
+batch_size = 32
+group_size = 4
+
+# alphabet-sort's feedback arrives as user messages, so train the user role
+# instead of echo's tool default.
+[orchestrator.algo.advantage]
+type = "echo"
+
+[orchestrator.algo.advantage.roles.user]
+alpha = 0.1
+
+[[orchestrator.train.env]]
+id = "alphabet-sort"
+args = { min_turns = 3, max_turns = 5, power_per_turn = false }
+
+[orchestrator.train.sampling]
+max_completion_tokens = 512
+
+# ECHO learns from observation tokens even when the GRPO advantage collapses
+# to zero — keep zero-advantage rollouts in the batch.
+[[orchestrator.post_batch_filters]]
+type = "zero_advantage"
+enforce = false
+
+# Qwen3 finetune with the standard PI template patch; always re-emits prior
+# blocks, matched by the qwen3 renderer's preserve_all_thinking.
+[orchestrator.renderer]
+name = "qwen3"
+preserve_all_thinking = true
+
+[trainer.optim]
+lr = 1e-6
+
+[inference]
+gpu_memory_utilization = 0.5
diff --git a/configs/debug/training_modes/rl.toml b/configs/debug/algorithms/grpo.toml
similarity index 88%
rename from configs/debug/training_modes/rl.toml
rename to configs/debug/algorithms/grpo.toml
index 27838809b3..7b95156925 100644
--- a/configs/debug/training_modes/rl.toml
+++ b/configs/debug/algorithms/grpo.toml
@@ -5,14 +5,16 @@ seq_len = 2048
name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
[wandb]
-project = "reverse-text-debug"
+project = "algorithms-debug"
name = "debug-rl"
[orchestrator]
-training_mode = "rl"
batch_size = 128
group_size = 16
+[orchestrator.algo.advantage]
+type = "grpo"
+
[orchestrator.renderer]
name = "qwen3"
diff --git a/configs/debug/algorithms/max_rl.toml b/configs/debug/algorithms/max_rl.toml
new file mode 100644
index 0000000000..2803d564b1
--- /dev/null
+++ b/configs/debug/algorithms/max_rl.toml
@@ -0,0 +1,43 @@
+max_steps = 20
+seq_len = 2048
+
+[model]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
+
+[wandb]
+project = "algorithms-debug"
+name = "debug-max-rl"
+
+[orchestrator]
+batch_size = 128
+group_size = 16
+
+[orchestrator.algo.advantage]
+type = "max_rl"
+
+[orchestrator.renderer]
+name = "qwen3"
+
+[orchestrator.train.sampling]
+max_completion_tokens = 128
+
+[[orchestrator.train.env]]
+id = "reverse-text"
+
+[orchestrator.eval]
+interval = 1
+num_examples = 128
+
+[orchestrator.eval.sampling]
+max_completion_tokens = 128
+
+[[orchestrator.eval.env]]
+id = "reverse-text"
+
+[trainer.optim]
+lr = 3e-6
+
+[ckpt]
+
+[inference]
+gpu_memory_utilization = 0.5
diff --git a/configs/debug/algorithms/mixed_grpo_opd.toml b/configs/debug/algorithms/mixed_grpo_opd.toml
new file mode 100644
index 0000000000..185a297fb8
--- /dev/null
+++ b/configs/debug/algorithms/mixed_grpo_opd.toml
@@ -0,0 +1,63 @@
+# Mixed per-env algorithms in one run: a GRPO env and an OPD env, both on
+# reverse-text. Exercises heterogeneous train batches — OPD samples ship
+# ref_logprobs, GRPO samples don't, and both pack into the same micro batches.
+# Start the frozen reference server first (on a separate GPU):
+# CUDA_VISIBLE_DEVICES=1 uv run inference \
+# --model.name PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL \
+# --server.port 8001 --gpu-memory-utilization 0.5 --model.enforce-eager
+# Then:
+# uv run rl @ configs/debug/algorithms/mixed_grpo_opd.toml
+
+max_steps = 20
+seq_len = 2048
+
+[model]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
+
+[wandb]
+project = "algorithms-debug"
+name = "debug-mixed-grpo-opd"
+
+[orchestrator]
+batch_size = 128
+group_size = 16
+
+[orchestrator.algo.advantage]
+type = "grpo"
+
+[orchestrator.renderer]
+name = "qwen3"
+
+[orchestrator.train.sampling]
+max_completion_tokens = 128
+
+[[orchestrator.train.env]]
+id = "reverse-text"
+name = "reverse-text-grpo"
+
+[[orchestrator.train.env]]
+id = "reverse-text"
+name = "reverse-text-opd"
+
+[orchestrator.train.env.algo.advantage]
+type = "opd"
+
+[orchestrator.train.env.algo.teacher]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
+base_url = ["http://localhost:8001/v1"]
+
+[orchestrator.eval]
+interval = 5
+num_examples = 128
+
+[orchestrator.eval.sampling]
+max_completion_tokens = 128
+
+[[orchestrator.eval.env]]
+id = "reverse-text"
+
+[trainer.optim]
+lr = 3e-6
+
+[inference]
+gpu_memory_utilization = 0.5
diff --git a/configs/debug/training_modes/opd.toml b/configs/debug/algorithms/opd.toml
similarity index 78%
rename from configs/debug/training_modes/opd.toml
rename to configs/debug/algorithms/opd.toml
index 39cbf6a604..0545ee5bef 100644
--- a/configs/debug/training_modes/opd.toml
+++ b/configs/debug/algorithms/opd.toml
@@ -1,9 +1,9 @@
-# Start the teacher inference server first (on a separate GPU):
+# Start the frozen reference server first (on a separate GPU):
# CUDA_VISIBLE_DEVICES=1 uv run inference \
# --model.name PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL \
# --server.port 8001 --gpu-memory-utilization 0.5 --model.enforce-eager
# Then:
-# uv run rl @ configs/debug/training_modes/opd.toml
+# uv run rl @ configs/debug/algorithms/opd.toml
max_steps = 20
seq_len = 2048
@@ -12,14 +12,20 @@ seq_len = 2048
name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
[wandb]
-project = "reverse-text-debug"
+project = "algorithms-debug"
name = "debug-opd"
[orchestrator]
-training_mode = "opd"
batch_size = 128
group_size = 16
+[orchestrator.algo.advantage]
+type = "opd"
+
+[orchestrator.algo.teacher]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
+base_url = ["http://localhost:8001/v1"]
+
[orchestrator.renderer]
name = "qwen3"
@@ -39,12 +45,6 @@ max_completion_tokens = 128
[[orchestrator.eval.env]]
id = "reverse-text"
-[orchestrator.teacher.model]
-name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
-
-[orchestrator.teacher.client]
-base_url = ["http://localhost:8001/v1"]
-
[trainer.optim]
lr = 3e-6
diff --git a/configs/debug/training_modes/opd_lora.toml b/configs/debug/algorithms/opd_lora.toml
similarity index 79%
rename from configs/debug/training_modes/opd_lora.toml
rename to configs/debug/algorithms/opd_lora.toml
index ba56ffea5c..20f66156be 100644
--- a/configs/debug/training_modes/opd_lora.toml
+++ b/configs/debug/algorithms/opd_lora.toml
@@ -1,9 +1,9 @@
-# Start the teacher inference server first (on a separate GPU):
+# Start the frozen reference server first (on a separate GPU):
# CUDA_VISIBLE_DEVICES=1 uv run inference \
# --model.name PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL \
# --server.port 8001 --gpu-memory-utilization 0.5 --model.enforce-eager
# Then:
-# uv run rl @ configs/debug/training_modes/opd_lora.toml
+# uv run rl @ configs/debug/algorithms/opd_lora.toml
max_steps = 20
seq_len = 2048
@@ -12,14 +12,20 @@ seq_len = 2048
name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
[wandb]
-project = "reverse-text-debug"
+project = "algorithms-debug"
name = "debug-opd-lora"
[orchestrator]
-training_mode = "opd"
batch_size = 128
group_size = 16
+[orchestrator.algo.advantage]
+type = "opd"
+
+[orchestrator.algo.teacher]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
+base_url = ["http://localhost:8001/v1"]
+
[orchestrator.renderer]
name = "qwen3"
@@ -39,12 +45,6 @@ max_completion_tokens = 128
[[orchestrator.eval.env]]
id = "reverse-text"
-[orchestrator.teacher.model]
-name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
-
-[orchestrator.teacher.client]
-base_url = ["http://localhost:8001/v1"]
-
[trainer.optim]
lr = 1e-4
diff --git a/configs/debug/algorithms/self_distill.toml b/configs/debug/algorithms/self_distill.toml
new file mode 100644
index 0000000000..b6eb09cf5e
--- /dev/null
+++ b/configs/debug/algorithms/self_distill.toml
@@ -0,0 +1,50 @@
+# Self-distillation (SDFT, https://arxiv.org/abs/2601.19897) against the live
+# policy itself: the reference for each completion is the current model
+# conditioned on the expert demonstration — no extra deployment needed.
+# reverse-text carries the demonstration in its top-level `answer` field.
+# uv run rl @ configs/debug/algorithms/self_distill.toml
+
+max_steps = 20
+seq_len = 2048
+
+[model]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
+
+[wandb]
+project = "algorithms-debug"
+name = "debug-self-distill"
+
+[orchestrator]
+batch_size = 32
+group_size = 1
+
+# reverse-text's demo lives in the "answer" column.
+[orchestrator.algo.advantage]
+type = "opsd"
+model = "policy"
+demo_key = "answer"
+
+[orchestrator.renderer]
+name = "qwen3"
+
+[orchestrator.train.sampling]
+max_completion_tokens = 128
+
+[[orchestrator.train.env]]
+id = "reverse-text"
+
+[orchestrator.eval]
+interval = 1
+num_examples = 128
+
+[orchestrator.eval.sampling]
+max_completion_tokens = 128
+
+[[orchestrator.eval.env]]
+id = "reverse-text"
+
+[trainer.optim]
+lr = 3e-6
+
+[inference]
+gpu_memory_utilization = 0.5
diff --git a/configs/debug/training_modes/sft.toml b/configs/debug/algorithms/sft_distill.toml
similarity index 55%
rename from configs/debug/training_modes/sft.toml
rename to configs/debug/algorithms/sft_distill.toml
index aed5b30cb3..fccbe64b69 100644
--- a/configs/debug/training_modes/sft.toml
+++ b/configs/debug/algorithms/sft_distill.toml
@@ -1,9 +1,9 @@
-# Start the teacher inference server first (on a separate GPU):
+# Start the frozen reference server first (on a separate GPU):
# CUDA_VISIBLE_DEVICES=1 uv run inference \
# --model.name PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL \
# --server.port 8001 --gpu-memory-utilization 0.5 --model.enforce-eager
# Then:
-# uv run rl @ configs/debug/training_modes/sft.toml
+# uv run rl @ configs/debug/algorithms/sft_distill.toml
max_steps = 20
seq_len = 2048
@@ -12,14 +12,28 @@ seq_len = 2048
name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
[wandb]
-project = "reverse-text-debug"
+project = "algorithms-debug"
name = "debug-sft"
[orchestrator]
-training_mode = "sft"
batch_size = 128
group_size = 4
+[orchestrator.algo.advantage]
+type = "sft"
+
+[orchestrator.algo.teacher]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
+base_url = ["http://localhost:8001/v1"]
+
+# The student's renderer tokenizes the teacher's messages into the student's
+# token space (backfill). Use `default` (wraps the student tokenizer's
+# apply_chat_template) so the CE target is faithful to the student's actual
+# chat template. The stock `qwen3` renderer injects an empty ``
+# block this model's custom template never emits, corrupting the target.
+[orchestrator.renderer]
+name = "default"
+
[orchestrator.train.sampling]
max_completion_tokens = 128
@@ -36,12 +50,6 @@ max_completion_tokens = 128
[[orchestrator.eval.env]]
id = "reverse-text"
-[orchestrator.teacher.model]
-name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
-
-[orchestrator.teacher.client]
-base_url = ["http://localhost:8001/v1"]
-
[trainer.optim]
lr = 3e-6
diff --git a/configs/debug/training_modes/sft_external.toml b/configs/debug/algorithms/sft_distill_external.toml
similarity index 51%
rename from configs/debug/training_modes/sft_external.toml
rename to configs/debug/algorithms/sft_distill_external.toml
index cb9ea8d09e..2e57883288 100644
--- a/configs/debug/training_modes/sft_external.toml
+++ b/configs/debug/algorithms/sft_distill_external.toml
@@ -1,8 +1,8 @@
-# SFT from openai/gpt-5-mini via PI inference.
+# SFT distillation from openai/gpt-5-mini via PI inference.
# Requires PRIME_API_KEY + PRIME_TEAM_ID in the environment.
#
# Run with:
-# uv run rl @ configs/debug/training_modes/sft_external.toml
+# uv run rl @ configs/debug/algorithms/sft_distill_external.toml
max_steps = 20
seq_len = 2048
@@ -11,14 +11,30 @@ seq_len = 2048
name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
[wandb]
-project = "reverse-text-debug"
+project = "algorithms-debug"
name = "debug-sft-external"
[orchestrator]
-training_mode = "sft"
batch_size = 128
group_size = 4
+[orchestrator.algo.advantage]
+type = "sft"
+
+[orchestrator.algo.teacher]
+name = "openai/gpt-5-mini"
+base_url = ["https://api.pinference.ai/api/v1"]
+api_key_var = "PRIME_API_KEY"
+headers_from_env."X-Prime-Team-ID" = "PRIME_TEAM_ID"
+
+# The student's renderer tokenizes the teacher's messages into the student's
+# token space (backfill). Use `default` (wraps the student tokenizer's
+# apply_chat_template) so the CE target is faithful to the student's actual
+# chat template. The stock `qwen3` renderer injects an empty ``
+# block this model's custom template never emits, corrupting the target.
+[orchestrator.renderer]
+name = "default"
+
[orchestrator.train.sampling]
max_completion_tokens = 2048
extra_body = { reasoning_effort = "minimal" }
@@ -36,16 +52,6 @@ max_completion_tokens = 128
[[orchestrator.eval.env]]
id = "reverse-text"
-[orchestrator.teacher.model]
-name = "openai/gpt-5-mini"
-
-[orchestrator.teacher.client]
-base_url = ["https://api.pinference.ai/api/v1"]
-api_key_var = "PRIME_API_KEY"
-
-[orchestrator.teacher.client.headers_from_env]
-X-Prime-Team-ID = "PRIME_TEAM_ID"
-
[trainer.optim]
lr = 3e-6
diff --git a/configs/debug/training_modes/sft_lora.toml b/configs/debug/algorithms/sft_distill_lora.toml
similarity index 57%
rename from configs/debug/training_modes/sft_lora.toml
rename to configs/debug/algorithms/sft_distill_lora.toml
index 687b45bbe3..8a91813c34 100644
--- a/configs/debug/training_modes/sft_lora.toml
+++ b/configs/debug/algorithms/sft_distill_lora.toml
@@ -1,9 +1,9 @@
-# Start the teacher inference server first (on a separate GPU):
+# Start the frozen reference server first (on a separate GPU):
# CUDA_VISIBLE_DEVICES=1 uv run inference \
# --model.name PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL \
# --server.port 8001 --gpu-memory-utilization 0.5 --model.enforce-eager
# Then:
-# uv run rl @ configs/debug/training_modes/sft_lora.toml
+# uv run rl @ configs/debug/algorithms/sft_distill_lora.toml
max_steps = 20
seq_len = 2048
@@ -12,14 +12,28 @@ seq_len = 2048
name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT"
[wandb]
-project = "reverse-text-debug"
+project = "algorithms-debug"
name = "debug-sft-lora"
[orchestrator]
-training_mode = "sft"
batch_size = 128
group_size = 4
+[orchestrator.algo.advantage]
+type = "sft"
+
+[orchestrator.algo.teacher]
+name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
+base_url = ["http://localhost:8001/v1"]
+
+# The student's renderer tokenizes the teacher's messages into the student's
+# token space (backfill). Use `default` (wraps the student tokenizer's
+# apply_chat_template) so the CE target is faithful to the student's actual
+# chat template. The stock `qwen3` renderer injects an empty ``
+# block this model's custom template never emits, corrupting the target.
+[orchestrator.renderer]
+name = "default"
+
[orchestrator.train.sampling]
max_completion_tokens = 128
@@ -36,12 +50,6 @@ max_completion_tokens = 128
[[orchestrator.eval.env]]
id = "reverse-text"
-[orchestrator.teacher.model]
-name = "PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL"
-
-[orchestrator.teacher.client]
-base_url = ["http://localhost:8001/v1"]
-
[trainer.optim]
lr = 1e-4
diff --git a/configs/debug/multi_env/reverse_text.toml b/configs/debug/multi_env/reverse_text.toml
index e57f65b1ff..66eaa21506 100644
--- a/configs/debug/multi_env/reverse_text.toml
+++ b/configs/debug/multi_env/reverse_text.toml
@@ -9,7 +9,6 @@ project = "reverse-text-debug"
name = "debug-multi-env"
[orchestrator]
-training_mode = "rl"
batch_size = 128
group_size = 16
diff --git a/configs/debug/training_modes/README.md b/configs/debug/training_modes/README.md
deleted file mode 100644
index 96ccebb009..0000000000
--- a/configs/debug/training_modes/README.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Training Mode — Debug Configs
-
-Minimal end-to-end configs for the three training modes (`rl` / `opd` / `sft`) against the `reverse-text` env, using `PrimeIntellect/Qwen3-0.6B-Reverse-Text-SFT` as the student.
-
-| Config | Mode | Teacher | Notes |
-|---|---|---|---|
-| `rl.toml` | `rl` | none | |
-| `opd.toml` | `opd` | local vLLM (`Qwen3-0.6B-Reverse-Text-RL`) | |
-| `opd_lora.toml` | `opd` | local vLLM (`Qwen3-0.6B-Reverse-Text-RL`) | trains a LoRA adapter (rank 8) |
-| `sft.toml` | `sft` | local vLLM (`Qwen3-0.6B-Reverse-Text-RL`) | |
-| `sft_lora.toml` | `sft` | local vLLM (`Qwen3-0.6B-Reverse-Text-RL`) | trains a LoRA adapter (rank 8) |
-| `sft_external.toml` | `sft` | PI inference (`openai/gpt-5-mini`) | external OAI endpoint; no local teacher |
-
-The student inference server is auto-launched on GPU 0 at `http://localhost:8000/v1` with `gpu_memory_utilization=0.5`. The local teacher (used by everything except `rl.toml` and `sft_external.toml`) is **not** auto-launched — start it manually on GPU 1.
-
-## Start the local teacher
-
-Needed for `opd*.toml` and `sft.toml` / `sft_lora.toml`:
-
-```bash
-CUDA_VISIBLE_DEVICES=1 uv run inference \
- --model.name PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL \
- --server.port 8001 \
- --gpu-memory-utilization 0.5 \
- --model.enforce-eager
-```
-
-## Run the debug configs
-
-```bash
-# RL (no teacher)
-uv run rl @ configs/debug/training_modes/rl.toml
-
-# OPD (needs teacher on port 8001)
-uv run rl @ configs/debug/training_modes/opd.toml
-uv run rl @ configs/debug/training_modes/opd_lora.toml
-
-# SFT hard distill (needs teacher on port 8001)
-uv run rl @ configs/debug/training_modes/sft.toml
-uv run rl @ configs/debug/training_modes/sft_lora.toml
-
-# SFT hard distill from openai/gpt-5-mini via PI inference
-# (requires PRIME_API_KEY + PRIME_TEAM_ID in env; no local teacher needed)
-uv run rl @ configs/debug/training_modes/sft_external.toml
-```
-
-See [docs/training.md](../../docs/training.md#training-modes-rl--opd--sft-via-orchestrator) for what each mode does.
diff --git a/configs/elastic/README.md b/configs/elastic/README.md
index 3772520eec..388d8cd26b 100644
--- a/configs/elastic/README.md
+++ b/configs/elastic/README.md
@@ -8,7 +8,7 @@ To test the elastic inference pool without having the `rl` entrypoint start the
uv run rl @ examples/alphabet_sort/rl_elastic.toml
```
- The config uses `[orchestrator.client.elastic]` with `hostname = "localhost"` and `port = 8000`. The orchestrator will wait for at least one ready server before starting rollouts.
+ The config uses `[orchestrator.model.client.elastic]` with `hostname = "localhost"` and `port = 8000`. The orchestrator will wait for at least one ready server before starting rollouts.
2. **Start the inference server manually** (in a separate terminal or on another machine):
diff --git a/configs/elastic/rl.toml b/configs/elastic/rl.toml
index 3073dc4537..7ac4bfd275 100644
--- a/configs/elastic/rl.toml
+++ b/configs/elastic/rl.toml
@@ -35,7 +35,7 @@ group_size = 8
[orchestrator.train.sampling]
max_completion_tokens = 768
-[orchestrator.client.elastic]
+[orchestrator.model.client.elastic]
hostname = "localhost"
port = 8000
sync_interval = 5.0
diff --git a/docs/algorithms.md b/docs/algorithms.md
index 0ffe69edba..d850cab525 100644
--- a/docs/algorithms.md
+++ b/docs/algorithms.md
@@ -1,25 +1,159 @@
# Algorithms
-This page covers the math and the configurable algorithmic components: how off-policy training works, the default loss and advantage functions, how to plug in your own, the filters applied between rollout and training, and how multi-turn rollouts get merged into training samples.
+This page covers the math and the configurable algorithmic components: the algorithm abstraction and its algorithms, how off-policy training works, the loss components and advantage functions, how to plug in your own, the filters applied between rollout and training, and how multi-turn rollouts get merged into training samples.
## Table of Contents
+- [The Algorithm Abstraction](#the-algorithm-abstraction)
+ - [Model References](#model-references)
+ - [The Algorithms](#the-algorithms)
+ - [Customizing Components](#customizing-components)
+ - [Per-Env Algorithms](#per-env-algorithms)
+ - [The Algorithm Classes](#the-algorithm-classes)
- [Async / Off-Policy Training](#async--off-policy-training)
- [Loss](#loss)
- - [Default Loss](#default-loss)
+ - [Loss Components](#loss-components)
+ - [Default RL Loss](#default-rl-loss)
- [Custom Loss](#custom-loss)
- [Advantage](#advantage)
- [Default Advantage](#default-advantage)
- [Custom Advantage](#custom-advantage)
+ - [Reference Scoring](#reference-scoring)
- [Filters](#filters)
-- [Difficulty Pools](#difficulty-pools)
-- [Online Difficulty Filtering](#online-difficulty-filtering)
- [Multi-Turn Trajectories](#multi-turn-trajectories)
- [Extension Property](#extension-property)
- [Best-Effort Interleaving](#best-effort-interleaving)
- [Renderers](#renderers)
- [Discontinuous Trajectories](#discontinuous-trajectories)
+## The Algorithm Abstraction
+
+A training algorithm in `prime-rl` is a bundle of two components, configured under `[orchestrator.algo]`:
+
+1. **Sampling** (`algo.sampling`) — how train rollouts are produced: which model generates them. `source` is a [model reference](#model-references): `"policy"` (the live policy, the default) or an inline frozen hosted model. Group sizing stays on the env config (`group_size`).
+2. **Advantage** (`algo.advantage`) — the per-token training signal: credit assignment and loss routing, fused. One mapping from a finalized rollout to per-token *(loss component, weight)* pairs — the credit a token gets and the loss that consumes it are two coordinates of the same output. Group-relative strategies compute credit on the orchestrator and ship per-token advantage streams; reference-KL strategies query a reference model at batch-ship time (bounded concurrency) and ship its prefill logprobs for the trainer to evaluate against the live policy. The strategy determines which loss component consumes the action tokens (`rl` / `ce` / `ref_kl`) and what happens to env-provided observation tokens in multi-turn rollouts (masked out by default; `echo` trains on them with weighted CE).
+
+The trainer is algorithm-blind: the loss is a sum of three components (rl, ce, ref_kl), each normalized by its own global token count; per-token streams ship on the wire (the `rl_weights` / `ce_weights` / `ref_kl_weights` component weights plus the `advantages` stream on each training sample) and the trainer just executes them. Adding an algorithm never touches the dispatcher, packer, or trainer hot path.
+
+### Model References
+
+`prime-rl` hosts exactly one model: the trainable policy (`[orchestrator.model]`). Every other model an algorithm uses is an external OpenAI-compatible endpoint, declared *inline on the component that uses it*. A model reference is either the string `"policy"` (the live policy) or a frozen hosted model (`name` + `base_url`):
+
+```toml
+[orchestrator.algo.advantage]
+type = "opd"
+
+[orchestrator.algo.teacher] # alias for `model`; folds into advantage.model
+name = "Qwen/Qwen3-32B"
+base_url = ["http://localhost:8001/v1"]
+```
+
+Model *roles* are labels the algorithm itself declares over these references — the distillation algorithms declare their reference's role as `teacher`, so `[orchestrator.algo.teacher]` parses as an alias for the `model` shorthand and validation errors speak the same language ("advantage 'opd' needs a teacher"). No role exists outside the algorithm that declares it: the dispatcher, sink, and trainer branch on liveness alone, never on what an algorithm calls a model.
+
+`algo.model` (alias: `algo.teacher`) is shorthand for the slot the advantage type declares for its reference — `advantage.model` for `opd` / `opsd`, `sampling.source` for `sft` (its teacher is the sampling source). A slot you didn't set takes the shorthand; an explicit reference that already equals it is accepted, a disagreeing one is an error. Set the component fields directly for multi-model setups.
+
+Liveness is a property of the reference, not of any role: rollouts sampled from `"policy"` get version-salted prefix caches, carry sampling logprobs for importance ratios, and age off-policy as weights update; rollouts and scores from frozen models get a stable prefix cache and never go stale. Frozen models are externally hosted (`base_url` is required) — `prime-rl` never launches or updates them, and each env's algorithm builds its own client pool to the endpoints it declares.
+
+### The Algorithms
+
+The advantage `type` names the algorithm, and each type's class defaults are its vetted setting — picking a type with no other keys IS the algorithm:
+
+```toml
+[orchestrator.algo.advantage]
+type = "grpo" # the default
+```
+
+| `type` | Sampling | Loss | What it is |
+|---|---|---|---|
+| `grpo` | policy | `rl` on actions | Standard group-relative RL. |
+| `max_rl` | policy | `rl` on actions | MaxRL ([arXiv:2602.02710](https://arxiv.org/abs/2602.02710)): GRPO's centered reward normalized by the group **mean** instead of the standard deviation — the gradient is unbiased for the order-`group_size` truncation of the maximum-likelihood objective, upweighting hard examples like `1/p`. |
+| `opd` | policy | `ref_kl` on actions | On-policy distillation ([Thinking Machines](https://thinkingmachines.ai/blog/on-policy-distillation/)): the policy samples, per-token reverse KL against a reference model as the gradient signal. Needs a `teacher`. |
+| `sft` | *(the teacher)* | `ce` on actions | Hard distillation: a frozen model generates rollouts, the policy trains with CE on its tokens. Needs a `teacher` (folds into `sampling.source`). |
+| `opsd` | policy | `ref_kl` on actions | SDFT ([arXiv:2601.19897](https://arxiv.org/abs/2601.19897)): the model is its own reference, conditioned on an expert demonstration. Defaults to the live policy (the paper's setting, no extra deployment); set an inline `model` to score under a frozen copy instead. |
+| `echo` | policy | `rl` on actions + weighted `ce` on observations | ECHO: standard GRPO plus a cross-entropy loss on env-provided tokens already present in the rollout, selected by message role (needs the renderer's role attribution). Defaults to tool-response bodies at `alpha = 0.1` (ECHO's λ); set `roles` to train other roles, each at its own weight. |
+| `reward` | policy | `rl` on actions | REINFORCE-style: advantage = raw reward, no group baseline. |
+| `custom` | policy | `rl` on actions | Your own advantage function (`import_path`), per-token advantages per rollout — see [Custom Advantage](#custom-advantage). |
+
+### Customizing Components
+
+Every key beyond `type` is visibly your own assembly — there is no preset layer to diverge from. The vetted setting is the class defaults; what you set is what runs:
+
+```toml
+# echo on tool AND user feedback tokens, each at its own weight.
+# Setting any role replaces the whole table.
+[orchestrator.algo.advantage]
+type = "echo"
+
+[orchestrator.algo.advantage.roles.tool]
+alpha = 0.25
+
+[orchestrator.algo.advantage.roles.user]
+alpha = 0.05
+
+# or a custom advantage strategy:
+# [orchestrator.algo.advantage]
+# type = "custom"
+# import_path = "my_module.normalized_advantage"
+```
+
+Echo also takes an optional user-supplied token filter that narrows the role selection per rollout — e.g. dropping warning lines from tool output, or tokens the sampler found unlikely:
+
+```toml
+[orchestrator.algo.advantage.filter]
+import_path = "my_module.drop_warnings"
+kwargs = { patterns = ["WARNING"] }
+```
+
+```python
+# my_module.py — sees the raw rollout (message text, sampling logprobs);
+# returns one keep-mask per trajectory step, spanning that step's
+# prompt_ids + completion_ids. False = never echo-trained.
+def drop_warnings(rollout, *, patterns: list[str]) -> list[list[bool]]: ...
+```
+
+Component compatibility is validated at config time: frozen-model sampling can only feed the `ce` loss component — the `rl` and `ref_kl` components need the live policy's own sampling logprobs for importance ratios — `opd` pointed at `"policy"` is rejected as degenerate (zero KL), `sft` without a frozen source is rejected (CE on the policy's own tokens is not a distillation target), and group-relative advantage with `group_size = 1` warns that every advantage collapses to zero.
+
+### Per-Env Algorithms
+
+Both components resolve per environment. Each env inherits `[orchestrator.algo]` unless it sets its own, so a single run can mix algorithms across envs — e.g. GRPO on math, ECHO on a terminal env:
+
+```toml
+[orchestrator.algo.advantage]
+type = "grpo"
+
+[[orchestrator.train.env]]
+id = "math-env" # inherits grpo
+
+[[orchestrator.train.env]]
+id = "terminal-env"
+advantage = { type = "echo" } # shorthand: the env assembles its own algorithm
+```
+
+### The Algorithm Classes
+
+At runtime, each env's resolved config builds two objects: a `Sampler` (`prime_rl.orchestrator.sampler`) from the `sampling` component — the pool rollouts are generated from, and the home of future sampling strategies like replay buffers or branching — and one of the named algorithm classes in `prime_rl.orchestrator.algo` (one module per algorithm: `algo/grpo.py`, `algo/opd.py`, …) from the `advantage` component. Algorithm dispatch is keyed on `advantage.type` — it names the algorithm, and each config class's defaults are its vetted parameterization:
+
+| `advantage.type` | Class | hook(s) — stage |
+|---|---|---|
+| `grpo` | `GRPOAlgorithm` | `score_group`: group-norm credit (optional length penalty) |
+| `echo` | `EchoAlgorithm` | `score_rollout`: weighted ce on observation tokens; `score_group`: group-norm credit (inherited) |
+| `max_rl` | `MaxRLAlgorithm` | `score_group`: mean-normalized group credit |
+| `opd` | `OPDAlgorithm` | `score_batch`: own-context prefill under the teacher |
+| `opsd` | `OPSDAlgorithm` | `score_batch`: demo-conditioned prefill under the teacher |
+| `sft` | `SFTDistillAlgorithm` | `score_group`: group-norm credit (feeds filters) |
+| `reward` | `RewardAlgorithm` | `score_rollout`: raw reward |
+| `custom` | `CustomAlgorithm` | `score_group`: your function |
+
+Each class owns its hooks outright — reading one top to bottom reads the algorithm, and everything on the class is an override point. The three hooks are one scope-and-timing ladder — each wider scope is unlocked by a later barrier, so the two axes coincide. Each is handed a `RolloutView` (a writable handle exposing only what is valid at its stage: `raw`, `samples`, `reward`, and `assign_advantages` — never not-yet-assigned credit or pipeline-internal lifecycle fields):
+
+- `score_rollout(rollout)` — one rollout, **on arrival** (as it's tokenized, before its group is complete): rollout-local credit (`rollout.assign_advantages(...)`, scalar broadcast or per-token) or observation ce weights. No siblings. `reward` writes its raw reward here; `echo` weights observation tokens here, reading interleaving's `obs_spans` provenance (which maps merged completion positions back to trajectory-step coordinates), looking up each source step's role attribution, applying the optional user filter, and writing the `ce_weights` stream.
+- `score_group(group)` — the cohort, **before filtering** (filters read the streams), synchronous: group-relative credit (GRPO/MaxRL baselines). `group` is a list of `RolloutView`.
+- `async score_batch(batch)` — the batch's survivors, **after filtering** (dropped rollouts never cost reference compute), async: the only stage with model access — query the algorithm's reference pool (e.g. `self.teacher_pool`, connected in its `setup()` override via `self.connect(...)` — the live policy pool when the reference is `"policy"`, a freshly connected client pool when frozen) and attach per-token results, or modulate advantages.
+
+The pipeline drives the hooks through three module-level phase functions it never looks inside: `finalize_rollout(algorithm, rollout)` per arrival, `finalize_group(algorithm, rollouts)` per group (scoring + wire stamping; after this the records are frozen — groups die at stamping), and `finalize_batch(train_envs, rollouts)` per batch. Sample construction (interleaving) is pure pipeline — it records the `obs_spans` provenance for any algorithm that trains on env-provided tokens.
+
+Class-level declarations state what the algorithm needs: which loss component its action tokens feed (`action_loss_type`) and what it calls its reference model (`model_role`, e.g. `"teacher"`). Every class is constructed with its advantage config — the component it interprets; the bundle dissolves at construction — plus the two host-owned resources: the policy pool and the policy's renderer. Text → token ids always goes through the renderer, the same path the policy's own prompts take (`opsd` requires one, validated at config time). The pipeline only ever calls the phase functions — writing your own algorithm is subclassing `Algorithm` and overriding the hooks its signal needs. For pure credit assignment, no subclass is needed: `advantage.type = "custom"` imports a plain advantage function (see [Custom Advantage](#custom-advantage)); custom reference scoring means forking one of the named classes. Shared math (group normalization, prefill alignment) lives as plain functions in `prime_rl.orchestrator.algo.advantage`.
+
## Async / Off-Policy Training
`prime-rl` is asynchronous by default. The trainer and inference always run one step overlapped: while the trainer is producing $\pi_n$ from rollouts at step $n$, inference is already generating the rollouts for step $n+1$ using $\pi_{n-1}$. With matched trainer and inference step times this produces fully-overlapped pipeline parallelism — neither side ever idles.
@@ -35,7 +169,21 @@ Step indices are 0-indexed so the gap holds at startup — inference is exactly
## Loss
-### Default Loss
+### Loss Components
+
+The training loss is a **sum of three components**, each with its own per-token weight stream and its own normalization:
+
+$$
+\mathcal{L} = \frac{\sum \mathcal{L}_{rl}}{N_{rl}} + \frac{\sum \mathcal{L}_{ce}}{N_{ce}} + \frac{\sum \mathcal{L}_{ref\_kl}}{N_{ref\_kl}}
+$$
+
+- `rl` — the configured RL loss (`[trainer.loss]`): DPPO + KL by default, or a [custom loss](#custom-loss). Fed by the group-relative advantage strategies (`grpo`, `max_rl`, `reward`, `custom`, and `echo`'s action tokens).
+- `ce` — masked NLL. Used for frozen-model tokens (`sft`) and env-observation tokens (`echo`).
+- `ref_kl` — the per-token reverse KL to a reference model ($\log \pi_{\text{ref}} - \log \pi$) as the policy-gradient signal, importance-ratio corrected with a one-sided trust region (`opd`, `opsd`). Requires `ref_logprobs` from a [reference scoring](#reference-scoring); the scoring model must be a vLLM server (it's the only one that exposes `prompt_logprobs`).
+
+The orchestrator stamps each sample's component membership as per-token weight streams (`rl_weights` / `ce_weights` / `ref_kl_weights` on the wire): a weight scales that component's per-token loss, `0.0` leaves the token out of the component entirely (mask *and* denominator), and components may overlap on the same token — their gradients sum. Each $N$ is the global (all-reduced) count of that component's member tokens, so the components don't dilute each other: adding echo observation tokens never changes the rl term's effective per-token learning rate, and an sft env packed next to a GRPO env doesn't soften its gradient. Tokens of different components pack freely into the same micro batch, and a plain GRPO run ships no weight streams at all (absent streams mean rl weight 1.0 on every trainable token — the unchanged hot path). Advantages always ship per token (`advantages` on the wire), assigned as per-token streams from the start — uniform group credit is broadcast over completion tokens at assignment; algorithms with no rl credit (opd, opsd) ship none.
+
+### Default RL Loss
The default RL loss is a DPPO policy-gradient term combined with a KL regularizer similar to Kimi-K2.5. For each prompt $x_j$ we sample a group of $G$ rollouts $\{y_i\}_{i=1}^G$, score them to get $s_i$, then optimize:
@@ -66,20 +214,14 @@ The knobs (under `[trainer.loss]` with `type = "default"`):
| Knob | Default | What it does |
|---|---|---|
| `dppo_mask_low` / `dppo_mask_high` | 0.2 / 0.2 | Lower / upper thresholds for DPPO-style token-level masking. |
-| `adv_tau` | 1.0 | Temperature on the advantage term. Set to 0 for pure distillation (no RL signal). |
+| `adv_tau` | 1.0 | Temperature on the advantage term. Set to 0 to drop the policy-gradient term, leaving only the KL regularizer. |
| `kl_tau` | 1e-3 | Temperature on the KL regularizer. Set to 0 to disable. |
-The trainer dispatches automatically based on the batch's training mode (set by the orchestrator via `orchestrator.training_mode`):
-
-- `rl` mode → DPPO + KL with the advantage signal.
-- `opd` mode → KL distillation against the teacher's per-token logprobs. The teacher must be a vLLM server (it's the only one that exposes `prompt_logprobs`).
-- `sft` mode → standard token-level NLL on teacher-generated rollouts.
-
-Set `[trainer.loss] type = "default"` and configure via the knobs above. SFT and OPD modes ignore the policy-gradient–specific fields.
+Set `[trainer.loss] type = "default"` and configure via the knobs above. The `ce` and `ref_kl` components are fixed and unaffected by `[trainer.loss]`.
### Custom Loss
-The loss is computed **per sequence**: you write a function that takes one sequence's tensors and returns a scalar loss. The trainer iterates and aggregates.
+`[trainer.loss] type = "custom"` replaces the `rl` component. The loss is computed **per sequence**: you write a function that takes one sequence's tensors and returns a scalar loss. The trainer iterates and aggregates. `inputs.loss_mask` selects exactly the rl member tokens (for a plain GRPO run, all trainable tokens).
```python
# my_module.py
@@ -116,9 +258,10 @@ The dataclasses:
class LossInputs:
trainer_logprobs: Float[Tensor, "seq"] # current policy
inference_logprobs: Float[Tensor, "seq"] # rollout-time policy
- teacher_logprobs: Float[Tensor, "seq"] | None # only set in OPD mode
+ ref_logprobs: Float[Tensor, "seq"] | None # set by reference-scoring algorithms
advantages: Float[Tensor, "seq"]
- loss_mask: Bool[Tensor, "seq"]
+ loss_mask: Bool[Tensor, "seq"] # this component's member tokens
+ loss_weights: Float[Tensor, "seq"] | None # the component's weight stream (None = 1.0)
@dataclass
class LossOutputs:
@@ -130,32 +273,59 @@ Anything you put in `metrics` is averaged across sequences and logged with the o
## Advantage
+The advantage strategy is the `advantage` component of the [algorithm](#the-algorithm-abstraction) — every training signal is a per-token advantage stream, varying in evaluation site (orchestrator vs. trainer). `[orchestrator.advantage]` (and per-env `advantage = {...}`) is shorthand for `algo.advantage`. Types:
+
+| Type | Component | Effect |
+|---|---|---|
+| `grpo` | `rl` | Group-norm: reward minus per-group baseline, optional length penalty. |
+| `max_rl` | `rl` | Mean-normalized group credit (maximum-likelihood RL). |
+| `echo` | `rl` + `ce` | Group-norm on action tokens, plus weighted CE on env-provided tokens selected by message role (each role's `alpha` is its ECHO λ), optionally narrowed by a user filter. |
+| `reward` | `rl` | Advantage = raw reward, no baseline. |
+| `opd` | `ref_kl` | On-policy distillation: per-token reverse KL to a reference model (`model`, an inline frozen hosted model), evaluated in the trainer from shipped reference logprobs. No credit — rollouts keep `advantages = None` (advantage-based filters never fire) and ship no advantage stream; `group_size` only fans out sampling. |
+| `opsd` | `ref_kl` | SDFT: per-token reverse KL to a demo-conditioned reference. No credit — rollouts keep `advantages = None` (advantage-based filters never fire) and ship no advantage stream. |
+| `sft` | `ce` | Cross-entropy on the sampled tokens. The loss ignores advantages, but group-relative credit is still assigned so reward-based filtering keeps working. |
+| `custom` | `rl` | Your function (below); per-token advantages per rollout. |
+
### Default Advantage
The default advantage is per-group reward minus per-group baseline (DR-GRPO without std normalization). For each prompt's group of `group_size` rollouts, every token in rollout $i$ receives advantage $s_i - \bar{s}$ where $\bar{s}$ is the group mean.
This is intentionally simple — it does the right thing for most envs. Switch to a [custom advantage](#custom-advantage) when you need group-aware shaping that depends on trajectory metadata (sub-agent rollouts, relative-rank shaping, …).
-Two built-in **length penalties** can be layered on top of any advantage to discourage rambling:
+Three built-in **length penalties** (`length_penalty` on the `grpo`-family strategies) can be layered on top to discourage rambling. `tokens` and `turns` are correctness-gated efficiency shaping: in mixed groups the lower-cost correct rollouts get amplified advantage (`tokens` by weighted token cost, `turns` by turn count). `linear` instead subtracts a `coef * pass_rate * (completion tokens / orchestrator.seq_len)` term from every reward before the baseline subtraction, where `pass_rate` is the group's mean reward — so problems the model already solves reliably get the strongest push toward concise outputs, while rarely-solved problems are barely penalized (a never-solved group, mean reward 0, gets none). Set `gate_by_correctness = true` to apply the linear penalty only to correct rollouts (`reward == 1`):
+
+```toml
+[orchestrator.advantage]
+type = "grpo"
+
+[orchestrator.advantage.length_penalty]
+type = "linear"
+coef = 0.25 # effective penalty is coef * pass_rate * (completion_tokens / seq_len)
+gate_by_correctness = false # when true, only penalize rollouts with reward == 1
+```
+
+By default the GRPO baseline is the plain group mean reward. Set `length_weighted_baseline = true` to instead use the token-length-weighted mean — `sum(len_i * reward_i) / sum(len_i)` — which centers advantages by per-token expected reward when rollouts vary a lot in length (it applies to the plain and `linear` paths; `tokens` / `turns` keep their own baseline):
-- `[orchestrator.length_penalty] type = "tokens"` — penalizes long completions in tokens, with configurable target and slope.
-- `[orchestrator.length_penalty] type = "turns"` — penalizes long multi-turn rollouts by turn count.
+```toml
+[orchestrator.advantage]
+type = "grpo"
+length_weighted_baseline = true
+```
### Custom Advantage
-Advantages are computed **per group**. You write a function that takes one group of rollouts and returns one advantage scalar per rollout. The orchestrator handles groups of varying size automatically — partial-group training kicks in when some rollouts in a group errored.
+Advantages are computed **per group**. You write a function that takes one group's `RolloutView`s — the same handles the `score_group` hook sees — and returns one value per rollout: a scalar (broadcast over that rollout's completion tokens) or a per-token list aligned to them (for multi-turn envs the merged completion, including interleaved observation tokens). There is no scalar advantage stored anywhere in the pipeline — the scalar is just a convenience the view broadcasts at write time. The orchestrator handles groups of varying size automatically — partial-group training kicks in when some rollouts in a group errored.
```python
# my_module.py
import statistics
-from prime_rl.orchestrator.advantage import AdvantageInputs, AdvantageOutputs
-def normalized_advantage(inputs: AdvantageInputs, eps: float = 1e-8) -> AdvantageOutputs:
- rewards = [r["reward"] for r in inputs.rollouts]
+def normalized_advantage(group, eps: float = 1e-8) -> list[float]:
+ rewards = [v.reward for v in group]
mean = statistics.fmean(rewards)
std = statistics.pstdev(rewards) if len(rewards) > 1 else 0.0
- return AdvantageOutputs(advantages=[(r - mean) / (std + eps) for r in rewards])
+ return [(r - mean) / (std + eps) for r in rewards] # one scalar per rollout
```
```toml
@@ -165,24 +335,39 @@ import_path = "my_module.normalized_advantage"
kwargs = { eps = 1e-8 }
```
-`AdvantageInputs.rollouts` is a list of `verifiers.RolloutOutput`, so you have access to the full rollout (turns, tool calls, custom metadata) — not just the reward. Use this for anything reward-shaping-like that needs trajectory context.
+Each `RolloutView` exposes `raw` (the env's untouched `verifiers.RolloutOutput`: turns, tool calls, custom metadata), `samples` (the merged token sequences), and `reward` — so you have the full interleaved rollout, not just the reward. Use this for anything reward-shaping-like that needs trajectory context.
+
+Genuinely per-token credit (process rewards, step-level credit assignment) returns shaped lists instead of scalars:
+
+```python
+def step_weighted_advantage(group) -> list[list[float]]:
+ rewards = [v.reward for v in group]
+ baseline = statistics.fmean(rewards)
+ return [
+ [(reward - baseline) * w for w in my_token_weights(view.raw)] # one float per completion token
+ for reward, view in zip(rewards, group)
+ ]
+```
-### Per-Env Advantage
+Each per-token list must match the rollout's completion-token count exactly — validated loudly when the view writes it. Advantage-based filters and metrics derive from the streams (the zero-advantage filter checks for all-zero streams; logged distributions use per-rollout means). Signals that depend on the live policy's weights (like OPD's reverse KL) cannot be precomputed here; those are reference-scoring algorithms, evaluated in the trainer.
-`advantage` can be set per training environment. Each env inherits the top-level `[orchestrator.advantage]` when it doesn't set its own, so mixed-env runs can give each env its own advantage computation:
+### Reference Scoring
-```toml
-[orchestrator.advantage]
-type = "default" # the default every env inherits unless it overrides
+`OPDAlgorithm` / `OPSDAlgorithm` have an async ship-time half (`score_batch`): at batch-ship time they query their teacher (`model`, a [model reference](#model-references)) with bounded concurrency (`max_concurrent`, default 32) and attach per-token reference logprobs to each sample:
-[[orchestrator.train.env]]
-id = "math-env" # inherits the default above
+- `opd` — score each sample's own context under the reference model via prefill; fills `ref_logprobs` for the `ref_kl` loss component (on-policy distillation). `model = "policy"` is rejected (the KL would be identically zero).
+- `opsd` — SDFT: rebuild the prompt with an expert demonstration woven into the last user message (`template`, with `{question}` / `{demonstration}` placeholders), score the policy's completion under that demo-conditioned context. `model = "policy"` scores under the live policy itself — the SDFT setting, no extra deployment. The demonstration is read from the example's `info[demo_key]`, falling back to a top-level rollout field of the same name (e.g. `answer`); single-step trajectories only.
-[[orchestrator.train.env]]
-id = "agent-env"
-advantage = { type = "custom", import_path = "my_module.normalized_advantage" }
+```toml
+[orchestrator.algo.advantage]
+type = "opsd"
+model = "policy"
+demo_key = "demonstration"
+max_concurrent = 64
```
+Only batch survivors get scored — rollouts that are filtered or cancelled never cost reference compute. The time shows up as `time/scoring` in the step timing.
+
## Filters
Filters drop rollouts between scoring and training. Built-ins (composable):
@@ -193,58 +378,19 @@ Filters drop rollouts between scoring and training. Built-ins (composable):
| `repetition` | Drops rollouts with high n-gram repetition. |
| `zero_advantage` | Drops rollouts whose advantage is zero, so the trainer doesn't waste tokens on them. |
-The default `[orchestrator]` config already includes all three filters with their defaults. To override, set `filters` explicitly — the list replaces the defaults wholesale:
+The default `[orchestrator]` config registers all three in both filter slots: `post_batch_filters` enforce by default (flagged rollouts are recorded but not shipped to the trainer), while `pre_batch_filters` run in monitor mode (`enforce = false`); flip `enforce = true` there to drop matching rollouts before they consume a slot in the batch. Setting a slot replaces its defaults wholesale:
```toml
-[[orchestrator.filters]]
+[[orchestrator.post_batch_filters]]
type = "zero_advantage"
-[[orchestrator.filters]]
+[[orchestrator.post_batch_filters]]
type = "repetition"
threshold = 0.4
```
Filtered rollouts still appear in W&B distributions, just not in the trainer batch — useful for spotting whether filtering is doing its job.
-## Difficulty Pools
-
-Difficulty pools gradually retire problems the model has solved or never solves. After each rollout, the average reward across a problem's group is compared to two thresholds:
-
-- `buffer.easy_threshold` — at or above this, the problem moves into the `easy` pool and is no longer sampled.
-- `buffer.hard_threshold` — at or below this, the problem moves into the `hard` pool and is no longer sampled.
-- Otherwise the problem stays in `normal` and remains in the sampling rotation.
-
-Pool assignments persist across checkpoints (`easy_examples.jsonl` / `hard_examples.jsonl` under each step's orchestrator checkpoint). When you resume — or want to broaden the curriculum mid-run — `buffer.easy_fraction` / `buffer.hard_fraction` randomly lift that fraction of pooled problems back into `normal` so they re-enter sampling.
-
-```toml
-[orchestrator.buffer]
-easy_threshold = 0.95
-hard_threshold = 0.05
-easy_fraction = 0.0 # default; bump on resume to bring some easy problems back
-hard_fraction = 0.0 # default; bump on resume to bring some hard problems back
-```
-
-Watch `pool/{env}/{easy,normal,hard}` (current pool ratios) and `evicted_examples/{env}/{easy,hard}` (per-step eviction rate).
-
-## Online Difficulty Filtering
-
-Online difficulty filtering (ODF) drops collapsed-advantage groups on the way *into* the buffer. Set `buffer.online_difficulty_filtering = true` (default `false`) to enable:
-
-- Average reward across the group is **0.0** (every rollout failed) → drop the group, count under `filtered_rollouts/{env}/hard`.
-- Average reward **1.0** (every rollout succeeded) → drop, count under `filtered_rollouts/{env}/easy`.
-- Otherwise → into the buffer.
-
-These are exactly the groups whose within-group advantage collapses to zero — DR-GRPO produces no gradient signal for them, so the trainer would burn step time on tokens it can't learn from.
-
-```toml
-[orchestrator.buffer]
-online_difficulty_filtering = true
-```
-
-**Tradeoff: trainer stability vs. inference speed.** With ODF on, every rollout that reaches the trainer carries non-zero advantage — each trainer step's effective batch is predictable and the gradient signal is denser. The cost is paid on the inference side: rollouts get produced and then thrown away, so the orchestrator has to oversample to keep the trainer fed. If the orchestrator is your bottleneck (`time/wait_for_batch` high on the trainer), ODF can starve the loop. Bump `orchestrator.oversampling_factor` so inference produces enough groups per step to absorb the drops.
-
-ODF is orthogonal to the [pools](#difficulty-pools): ODF reacts to the *current* group's reward distribution, the pools track the *running* per-problem average. Many configs use both — ODF for per-step density, pools for long-horizon curriculum cleanup.
-
## Multi-Turn Trajectories
Multi-turn rollouts (tool use, browser environments, long conversations) used to be stitched into a single fake "single-turn" sample, which silently corrupted the importance ratio when chat templates didn't roundtrip. Since [`verifiers` v0.1.8](https://github.com/PrimeIntellect-ai/verifiers/releases/tag/v0.1.8), `prime-rl` records each LLM request/response as an independent **trajectory step** and merges them at training time using best-effort interleaving — with [renderers](#renderers) as the mechanism that keeps the merge safe by construction.
diff --git a/docs/inference.md b/docs/inference.md
index d9b1e9947c..35a1de242c 100644
--- a/docs/inference.md
+++ b/docs/inference.md
@@ -176,12 +176,12 @@ Right now, router handles 2 most important things:
### Routing policies
The 2 policies you might want to configure are:
- `consistent_hash` - this is the default policy that optimizes for KV cache re-use across turns - this works by hashing a request header to determine where to route the request to. You can configure what to hash by setting
-`orchestrator.student.client.extra_headers_from_state` to the header the `router` expects to be set.
+`orchestrator.model.client.extra_headers_from_state` to the header the `router` expects to be set.
We set it to a sensible default, that works with all verifiers environments.
```toml
-[orchestrator.student.client.extra_headers_from_state]
+[orchestrator.model.client.extra_headers_from_state]
X-Session-ID = "trajectory_id" # this is the default - each rollout has a unique trajectory_id and router expects X-Session-ID
```
diff --git a/docs/scaling.md b/docs/scaling.md
index 84ef0db7df..3e9cc65404 100644
--- a/docs/scaling.md
+++ b/docs/scaling.md
@@ -41,7 +41,7 @@ uv run rl @ rl.toml \
--inference.parallel.dp 6
```
-The launcher allocates GPUs in order from `CUDA_VISIBLE_DEVICES` (or all visible GPUs): inference first, trainer next, teacher last. To target a specific physical subset, pin `CUDA_VISIBLE_DEVICES` before launching.
+The launcher allocates GPUs in order from `CUDA_VISIBLE_DEVICES` (or all visible GPUs): inference first, trainer next. To target a specific physical subset, pin `CUDA_VISIBLE_DEVICES` before launching.
For quick A/B ablations on the same node, run two RL instances side-by-side in separate tmux sessions, each pinned to half the GPUs and a separate inference port:
@@ -54,7 +54,7 @@ CUDA_VISIBLE_DEVICES=0,1 uv run rl @ rl.toml --output-dir outputs/exp1
bash scripts/tmux.sh -s exp2 -o outputs/exp2
CUDA_VISIBLE_DEVICES=2,3 uv run rl @ rl.toml \
--inference.server.port 8001 \
- --orchestrator.client.base-url http://localhost:8001/v1 \
+ --orchestrator.model.client.base-url http://localhost:8001/v1 \
--output-dir outputs/exp2
```
@@ -237,7 +237,7 @@ gpus_per_node = 8
Full multi-node configs ship in [`examples/multinode/`](https://github.com/PrimeIntellect-ai/prime-rl/tree/main/examples/multinode):
-- [`rl.toml`](https://github.com/PrimeIntellect-ai/prime-rl/blob/main/examples/multinode/rl.toml) — two-node RL run with NCCL weight broadcast on a 30B MoE student.
+- [`rl.toml`](https://github.com/PrimeIntellect-ai/prime-rl/blob/main/examples/multinode/rl.toml) — two-node RL run with NCCL weight broadcast on a 30B MoE policy.
- [`sft.toml`](https://github.com/PrimeIntellect-ai/prime-rl/blob/main/examples/multinode/sft.toml) — two-node SFT against the same model.
For inference-only multi-node, set `[deployment] type = "multi_node"` on an inference TOML — each node runs an independent vLLM replica (TP and DP must fit within one node), and the launcher prints one URL per node. Front the URLs with a router or point clients at any of them.
diff --git a/docs/training.md b/docs/training.md
index 53fc7fa7aa..9c332b7eca 100644
--- a/docs/training.md
+++ b/docs/training.md
@@ -1,6 +1,6 @@
# Training
-This page covers everything you need to launch, observe, checkpoint, and recover a `prime-rl` training run — the RL trainer, the SFT trainer, and the related on-policy distillation mode. For multi-node and cluster layouts, see [Scaling](scaling.md). For the loss math and algorithm knobs, see [Algorithms](algorithms.md).
+This page covers everything you need to launch, observe, checkpoint, and recover a `prime-rl` training run — the RL trainer (and the distillation algorithms that run through it) and the SFT trainer. For multi-node and cluster layouts, see [Scaling](scaling.md). For the loss math and algorithm knobs, see [Algorithms](algorithms.md).
> **AI agents working in this repo:** the equivalent runbooks are at [`skills/training/`](https://github.com/PrimeIntellect-ai/prime-rl/tree/main/skills/training) — top-level routing in [`skills/training/SKILL.md`](https://github.com/PrimeIntellect-ai/prime-rl/blob/main/skills/training/SKILL.md), launch details in [`skills/training/start-run/SKILL.md`](https://github.com/PrimeIntellect-ai/prime-rl/blob/main/skills/training/start-run/SKILL.md), and check-in / restart procedures in [`skills/training/monitor-run/SKILL.md`](https://github.com/PrimeIntellect-ai/prime-rl/blob/main/skills/training/monitor-run/SKILL.md).
@@ -10,7 +10,7 @@ This page covers everything you need to launch, observe, checkpoint, and recover
- [RL Trainer](#rl-trainer)
- [Launch](#launch)
- [Useful Knobs](#useful-knobs)
- - [Training Modes (RL / OPD / SFT)](#training-modes-rl--opd--sft)
+ - [Algorithms](#algorithms)
- [Important Metrics](#important-metrics)
- [SFT Trainer](#sft-trainer)
- [Dataset Format](#dataset-format)
@@ -59,7 +59,7 @@ A condensed view of the knobs you'll most often tune. For trainer-side paralleli
| `orchestrator.batch_size` | Tasks per trainer step. |
| `orchestrator.group_size` | Rollouts generated per task. |
| `orchestrator.max_off_policy_steps` | How many distinct policies may have contributed to one rollout before it's discarded (default 8). The main off-policy dial on long agentic rollouts — bump for throughput, lower for tighter on-policyness. Watch `errored_rollouts` and `mismatch_kl/all/mean` when tuning. |
-| `orchestrator.training_mode` | `rl` (default), `opd`, or `sft`. See [Training modes](#training-modes-rl--opd--sft). |
+| `[orchestrator.algo]` | Training algorithm — the advantage `type` names it (`grpo` default, `max_rl`, `opd`, `opsd`, `sft`, `echo`, `reward`, `custom`). See [Algorithms](#algorithms). |
| `[[orchestrator.train.env]]` | Training environments. List multiple tables for multi-env training; weight them via `ratio`. See [Configuration § Environments](configuration.md#environments-orchestratortrainenv). |
| `[[orchestrator.eval.env]]` + `orchestrator.eval.interval` | Eval environments and cadence (default every 100 steps). |
@@ -81,24 +81,29 @@ A condensed view of the knobs you'll most often tune. For trainer-side paralleli
| `--max-steps N` | Stop after `N` trainer steps. Overrides the config value. |
| `--dry-run` | Resolve + validate the full config, write per-process TOMLs to `/configs/`, and exit without launching. The fastest way to debug a misbehaving config. |
-### Training Modes (RL / OPD / SFT)
+### Algorithms
-The RL entrypoint supports three training modes, switched via `orchestrator.training_mode`:
+The RL entrypoint supports several training algorithms, switched via `[orchestrator.algo.advantage]` (see [Algorithms](algorithms.md#the-algorithm-abstraction) for the full reference, model references, and per-component customization):
-| Mode | Student | Teacher | Use case |
-|---|---|---|---|
-| `rl` | Required | Forbidden | Standard RL |
-| `opd` | Required | Required, must be vLLM (needs `prompt_logprobs`) | [On-policy distillation](https://thinkingmachines.ai/blog/on-policy-distillation/): student generates rollouts, trainer minimizes KL to teacher logprobs |
-| `sft` | Required | Required, any OpenAI-compatible endpoint | Hard-distill: teacher generates rollouts, student trains on them |
+| `advantage.type` | Frozen model (`algo.teacher`) | Use case |
+|---|---|---|
+| `grpo` (default) | None | Standard group-relative RL |
+| `max_rl` | None | [MaxRL](https://arxiv.org/abs/2602.02710): GRPO with mean-normalized advantages (maximum-likelihood RL) |
+| `opd` | Required, must be vLLM (needs `prompt_logprobs`) | [On-policy distillation](https://thinkingmachines.ai/blog/on-policy-distillation/): the policy generates rollouts, the trainer minimizes per-token reverse KL to a reference model |
+| `sft` | Required, any OpenAI-compatible endpoint | Hard-distill: a frozen model generates rollouts, the policy trains on its tokens |
+| `opsd` | `"policy"` (the default, no deployment) or a vLLM endpoint serving a frozen copy | [SDFT](https://arxiv.org/abs/2601.19897): the model is its own reference conditioned on expert demonstrations |
+| `echo` | None | GRPO plus cross-entropy on env-observation tokens |
+
+`reward` (raw-reward credit, no baseline) and `custom` (your own advantage function) complete the set — see [Algorithms § The Algorithms](algorithms.md#the-algorithms).
-The `rl` entrypoint only manages student-policy inference. For OPD and (local-vLLM) SFT, start the teacher inference server manually and point `[orchestrator.teacher.client]` at it:
+Frozen models are declared inline on the algorithm (`[orchestrator.algo.teacher]` with `name` + `base_url`). The `rl` entrypoint only manages policy inference — start frozen-model servers yourself and point `base_url` at them:
```bash
CUDA_VISIBLE_DEVICES=1 uv run inference \
- --model.name --server.port 8001
+ --model.name --server.port 8001
```
-The standalone `uv run sft` entrypoint is the more traditional SFT path — pure dataset-based, no teacher, no orchestrator. Use `orchestrator.training_mode = "sft"` only when you want a teacher to generate the supervision on the fly.
+The standalone `uv run sft` entrypoint is the more traditional SFT path — pure dataset-based, no orchestrator. Use the `sft` algorithm only when you want a frozen model to generate the supervision on the fly.
### Important Metrics
diff --git a/examples/glm5_pd_disag/rl.toml b/examples/glm5_pd_disag/rl.toml
index 878e79198b..d3a2689f9a 100644
--- a/examples/glm5_pd_disag/rl.toml
+++ b/examples/glm5_pd_disag/rl.toml
@@ -66,7 +66,7 @@ group_size = 16
oversampling_factor = 3
max_off_policy_steps = 16
-[orchestrator.student.model]
+[orchestrator.model]
name = "zai-org/GLM-5-FP8"
[orchestrator.eval]
diff --git a/packages/prime-rl-configs/src/prime_rl/configs/algorithm.py b/packages/prime-rl-configs/src/prime_rl/configs/algorithm.py
new file mode 100644
index 0000000000..664cfdf63f
--- /dev/null
+++ b/packages/prime-rl-configs/src/prime_rl/configs/algorithm.py
@@ -0,0 +1,441 @@
+"""Algorithm abstraction: sampling and the per-token training signal.
+
+An algorithm is a bundle of two pieces:
+
+1. **Sampling** — which model generates train rollouts. ``source`` is a model
+ reference: ``"policy"`` (the live policy) or an inline frozen hosted model.
+2. **Advantage** — credit assignment and loss routing, fused: one mapping from
+ a finalized rollout to per-token ``(loss component, weight)``.
+ Group-relative strategies compute scalars on the orchestrator and ship
+ numbers; reference-KL strategies ship reference prefill logprobs and the
+ trainer evaluates the per-token signal against the live policy. The
+ strategy determines which loss component consumes the action tokens
+ (``rl`` / ``ce`` / ``ref_kl``) and what happens to env-provided observation
+ tokens (masked out by default; ``echo`` trains on them with weighted CE).
+
+prime-rl only ever hosts the trainable policy. Every other model an algorithm
+uses is an external OpenAI-compatible endpoint, declared inline on the
+component that uses it (a :class:`FrozenModelConfig`). Model roles like
+"teacher" are algorithm-local vocabulary over these references; the pipeline
+branches on liveness alone. The advantage ``type`` names the algorithm and its
+class defaults are the vetted setting — ``type = "opd"`` with nothing else IS
+on-policy distillation; any key you set is visibly your own assembly. The
+trainer is algorithm-blind: the loss is a sum of three components (rl, ce,
+ref_kl), each normalized by its own global token count; per-token component
+weights ship on the wire and the trainer just executes them.
+"""
+
+import warnings
+from typing import Annotated, Any, ClassVar, Literal, TypeAlias
+
+from pydantic import AliasChoices, Field, model_validator
+
+from prime_rl.configs.shared import ClientConfig
+from prime_rl.utils.config import BaseConfig
+
+
+class FrozenModelConfig(ClientConfig):
+ """An externally hosted model behind an OpenAI-compatible endpoint: the
+ client config plus the served model's ``name``.
+
+ prime-rl never launches or updates these — only the trainable policy is
+ ever hosted by prime-rl itself. Frozen models are reachable-but-unmanaged:
+ ``base_url`` is required, their weights never change, and rollouts or
+ scores from them never go stale (stable prefix cache, no off-policy
+ aging)."""
+
+ name: str
+ """Served model name, sent as the ``model`` field of every request."""
+
+ @model_validator(mode="after")
+ def require_explicit_endpoint(self):
+ if "base_url" not in self.model_fields_set and not self.is_elastic:
+ raise ValueError(
+ "a frozen model reference needs base_url — frozen models are externally "
+ "hosted; prime-rl only ever hosts the trainable policy."
+ )
+ return self
+
+
+ModelReference: TypeAlias = Literal["policy"] | FrozenModelConfig
+"""``"policy"`` (the live policy — weight-updated: prefix caches salted per
+version, sampling logprobs carried, rollouts age off-policy) or an inline
+externally-hosted frozen model."""
+
+ActionLossType: TypeAlias = Literal["rl", "ce", "ref_kl"]
+
+
+# ---------------------------------------------------------------------------
+# Component 1: sampling
+# ---------------------------------------------------------------------------
+
+
+class SamplingConfig(BaseConfig):
+ source: ModelReference = "policy"
+ """Model reference for train rollout generation: ``"policy"`` (the live
+ policy — prefix caches salted per version, sampling logprobs requested,
+ rollouts age off-policy) or an inline frozen hosted model (stable prefix
+ cache, no sampling logprobs, rollouts never go stale)."""
+
+
+# ---------------------------------------------------------------------------
+# Component 2: advantage strategies
+# ---------------------------------------------------------------------------
+
+
+class TokensLengthPenaltyConfig(BaseConfig):
+ type: Literal["tokens"] = "tokens"
+
+ completion_weight: float = Field(1.0, ge=0, allow_inf_nan=False)
+ """Weight on model completion tokens. Finite and non-negative."""
+
+ tool_response_weight: float = Field(1.0, ge=0, allow_inf_nan=False)
+ """Weight on tool-response tokens (read from the rollout's ``*_total_tool_response_tokens`` harness metric; 0 if absent). Finite and non-negative."""
+
+
+class TurnsLengthPenaltyConfig(BaseConfig):
+ type: Literal["turns"] = "turns"
+
+
+class LinearLengthPenaltyConfig(BaseConfig):
+ type: Literal["linear"] = "linear"
+
+ coef: float = Field(0.25, ge=0, allow_inf_nan=False)
+ """Scale on the linear length penalty. Each reward is reduced by ``coef * pass_rate * (model completion tokens / orchestrator.seq_len)`` — where ``pass_rate`` is the group's mean reward — before the GRPO baseline subtraction. Finite and non-negative."""
+
+ gate_by_correctness: bool = False
+ """When True, scale each rollout's penalty by its reward (``penalty * reward``), so correct rollouts (``reward == 1``) are penalized and incorrect ones (``reward == 0``) are not. When False, every rollout is penalized equally."""
+
+
+LengthPenaltyConfig: TypeAlias = Annotated[
+ TokensLengthPenaltyConfig | TurnsLengthPenaltyConfig | LinearLengthPenaltyConfig,
+ Field(discriminator="type"),
+]
+
+
+class GRPOAdvantageConfig(BaseConfig):
+ type: Literal["grpo"] = "grpo"
+ """GRPO: scalar advantage = reward minus the per-group mean baseline,
+ consumed by the ``rl`` loss component on the rollout's action tokens."""
+
+ action_loss_type: ClassVar[ActionLossType] = "rl"
+ group_relative: ClassVar[bool] = True
+
+ length_penalty: LengthPenaltyConfig | None = None
+ """Length penalty layered onto the group-relative advantage; None disables it. ``tokens`` / ``turns`` are correctness-gated efficiency shaping over a per-rollout cost — in mixed groups lower-cost correct rollouts get amplified advantage (up to 2x), higher-cost correct rollouts are unchanged, incorrect untouched; in all-correct groups below-average-cost rollouts get advantage in [0, 1], others get 0. ``linear`` instead subtracts a ``coef * pass_rate * (completion tokens / orchestrator.seq_len)`` term from each reward before the baseline subtraction (``pass_rate`` = group mean reward), so solved-often problems get the strongest concision pressure and never-solved groups get none."""
+
+ length_weighted_baseline: bool = False
+ """When True, the GRPO baseline is the token-length-weighted mean reward (``sum(len_i * reward_i) / sum(len_i)``) instead of the plain group mean, centering advantages by per-token expected reward. Applies to the plain and ``linear``-penalty paths; the ``tokens`` / ``turns`` efficiency-shaping paths keep their own baseline."""
+
+
+class EchoRoleConfig(BaseConfig):
+ """Echo CE supervision for one message role."""
+
+ alpha: float = Field(0.1, gt=0)
+ """Per-token ce weight for this role's env-provided tokens (ECHO's lambda)."""
+
+
+class EchoRolesConfig(BaseConfig):
+ """Which env-provided message roles train, each at its own weight.
+ Setting any role replaces the whole table — unset roles stay disabled."""
+
+ system: EchoRoleConfig | None = None
+ user: EchoRoleConfig | None = None
+ assistant: EchoRoleConfig | None = None
+ tool: EchoRoleConfig | None = None
+
+ @model_validator(mode="after")
+ def require_a_role(self):
+ if self.system is None and self.user is None and self.assistant is None and self.tool is None:
+ raise ValueError("echo needs at least one role enabled (system, user, assistant, or tool)")
+ return self
+
+
+class EchoFilterConfig(BaseConfig):
+ """User-supplied per-token filter narrowing the role-selected echo tokens.
+
+ The callable is imported at startup and invoked once per rollout as
+ ``filter_fn(rollout, **kwargs) -> list[list[bool]]`` — one keep-mask per
+ trajectory step, each spanning that step's ``prompt_ids`` +
+ ``completion_ids``. Tokens with ``False`` never receive echo weight; the
+ filter can only narrow the role selection, not widen it. The raw rollout
+ exposes message text and sampling logprobs, so content filters (e.g.
+ dropping tool-output warnings) and sampling-probability filters need no
+ extra framework surface."""
+
+ import_path: str
+ """Import path to the filter callable (e.g. ``my_module.drop_warnings``)."""
+
+ kwargs: dict[str, Any] = Field(default_factory=dict)
+ """Kwargs forwarded to the filter."""
+
+
+class EchoAdvantageConfig(GRPOAdvantageConfig):
+ type: Literal["echo"] = "echo" # type: ignore[assignment]
+ """ECHO: group-relative advantage on action tokens (GRPO), plus weighted
+ CE on env-provided tokens of later turns (tool output, user feedback),
+ selected by message role via the renderer's per-token attribution
+ (requires ``orchestrator.renderer``; MITO rollouts carry no attribution).
+ Selected tokens feed the ``ce`` loss component at their role's ``alpha``
+ and stay outside the rl mask and its denominator."""
+
+ roles: EchoRolesConfig = EchoRolesConfig(tool=EchoRoleConfig())
+ """The role table. The default — tool-response bodies at ``alpha = 0.1``
+ — is the vetted ECHO setting."""
+
+ filter: EchoFilterConfig | None = None
+ """Optional user-supplied filter narrowing the role-selected tokens."""
+
+
+class MaxRLAdvantageConfig(BaseConfig):
+ type: Literal["max_rl"] = "max_rl"
+ """MaxRL (arXiv:2602.02710): scalar advantage = (reward − group mean) /
+ group mean, consumed by the ``rl`` loss component. Normalizing by the
+ mean instead of GRPO's standard deviation makes the policy gradient
+ unbiased for the order-``group_size`` truncation of the maximum-likelihood
+ objective: low-pass-rate examples get ~1/p weight, and ``group_size`` is
+ the truncation order interpolating REINFORCE (1) → exact maximum
+ likelihood (∞). Designed for non-negative (canonically binary) rewards;
+ a group with mean reward 0 carries zero advantages everywhere (the
+ zero-advantage filter drops it, matching the paper's K=0 convention)."""
+
+ action_loss_type: ClassVar[ActionLossType] = "rl"
+ group_relative: ClassVar[bool] = True
+
+
+class RewardAdvantageConfig(BaseConfig):
+ type: Literal["reward"] = "reward"
+ """Scalar advantage = raw reward, no group baseline. Consumed by the
+ ``rl`` loss component."""
+
+ action_loss_type: ClassVar[ActionLossType] = "rl"
+ group_relative: ClassVar[bool] = False
+
+
+class OPDAdvantageConfig(BaseConfig):
+ type: Literal["opd"] = "opd"
+ """On-policy distillation: the per-token signal is the reverse KL to
+ a reference model, evaluated in the trainer from reference prefill
+ logprobs scored over each sample's own context (``ref_logprobs`` on the
+ wire, ``ref_kl`` loss component). No scalar advantage is assigned —
+ rollouts keep ``advantage=None`` (advantage-based filters never fire) and
+ samples ship a neutral 0.0; rewards still flow to metrics. ``group_size``
+ only fans out sampling."""
+
+ action_loss_type: ClassVar[ActionLossType] = "ref_kl"
+ group_relative: ClassVar[bool] = False
+ model_role: ClassVar[str] = "teacher"
+
+ model: ModelReference | None = None
+ """The teacher — an inline frozen hosted model (``name`` + ``base_url``).
+ Required — set it here or fold via ``algo.model`` / ``algo.teacher``.
+ ``"policy"`` is rejected: scoring the policy under itself yields zero KL
+ signal (use ``opsd`` for demo-conditioned self-teaching)."""
+
+ max_concurrent: int = Field(32, ge=1)
+ """Maximum concurrent prefill requests per batch."""
+
+
+class OPSDAdvantageConfig(BaseConfig):
+ type: Literal["opsd"] = "opsd"
+ """On-policy self-distillation (SDFT, https://arxiv.org/abs/2601.19897):
+ the per-token signal is the reverse KL to a reference model conditioned on
+ an expert demonstration. The scoring prefix is rebuilt from the rollout's
+ first-turn messages with the demonstration woven into the user message via
+ ``template``; completion logprobs are aligned back onto the sample.
+ Requires single-step trajectories. No scalar advantage is assigned —
+ rollouts keep ``advantage=None`` (advantage-based filters never fire) and
+ samples ship a neutral 0.0."""
+
+ action_loss_type: ClassVar[ActionLossType] = "ref_kl"
+ group_relative: ClassVar[bool] = False
+ model_role: ClassVar[str] = "teacher"
+
+ model: ModelReference = "policy"
+ """The teacher. ``"policy"`` (the default) is the SDFT paper's setting —
+ the current model conditioned on the demo *is* the teacher — and needs no
+ extra deployment. Set an inline frozen hosted model to score under a
+ frozen copy instead."""
+
+ demo_key: str = "demonstration"
+ """Key holding the expert demonstration text — looked up in the example's
+ ``info`` dict first, then as a top-level rollout field (e.g. ``answer``)."""
+
+ template: str = (
+ "{question}\n\n"
+ "Here is an example of an expert response:\n"
+ "\n{demonstration}\n\n\n"
+ "Answer with a response of your own."
+ )
+ """Template for the demo-conditioned user message. Receives ``{question}``
+ (the original user message text) and ``{demonstration}``."""
+
+ max_concurrent: int = Field(32, ge=1)
+ """Maximum concurrent prefill requests per batch."""
+
+
+class SFTAdvantageConfig(BaseConfig):
+ type: Literal["sft"] = "sft"
+ """SFT distillation: cross-entropy on the sampled tokens. The ``ce``
+ loss component ignores scalar advantages, but group-relative scalars are still
+ assigned so reward-based filtering keeps working (the zero-advantage
+ filter drops uniform-reward groups)."""
+
+ action_loss_type: ClassVar[ActionLossType] = "ce"
+ group_relative: ClassVar[bool] = True
+ source_role: ClassVar[str] = "teacher"
+ """The sampling source is this algorithm's teacher — the frozen model
+ whose tokens the policy trains on. Required: CE on the policy's own
+ tokens is rejected at validation."""
+
+
+class CustomAdvantageConfig(BaseConfig):
+ type: Literal["custom"] = "custom"
+ """Custom advantage function, consumed by the ``rl`` loss component. Returns
+ one scalar per rollout, optionally with per-token advantages aligned to
+ each rollout's completion tokens."""
+
+ action_loss_type: ClassVar[ActionLossType] = "rl"
+ group_relative: ClassVar[bool] = False
+
+ import_path: str
+ """Import path to the advantage function (e.g. ``my_module.my_advantage``)."""
+
+ kwargs: dict[str, Any] = Field(default_factory=dict)
+ """Kwargs forwarded to the advantage function."""
+
+
+AdvantageConfig: TypeAlias = Annotated[
+ GRPOAdvantageConfig
+ | EchoAdvantageConfig
+ | MaxRLAdvantageConfig
+ | RewardAdvantageConfig
+ | OPDAdvantageConfig
+ | OPSDAdvantageConfig
+ | SFTAdvantageConfig
+ | CustomAdvantageConfig,
+ Field(discriminator="type"),
+]
+
+
+# ---------------------------------------------------------------------------
+# The algorithm bundle
+# ---------------------------------------------------------------------------
+
+
+class AlgorithmConfig(BaseConfig):
+ """The advantage ``type`` names the algorithm, and each type's class
+ defaults are its vetted setting — ``advantage = { type = "opd" }`` with a
+ teacher IS on-policy distillation; any other key you set is visibly your
+ own assembly.
+
+ The algorithms:
+
+ - ``grpo`` — policy group sampling, group-relative advantage, RL loss (the default).
+ - ``max_rl`` — GRPO with mean-normalized advantages (maximum-likelihood RL).
+ - ``opd`` — on-policy distillation: policy samples, per-token reverse KL against a reference model. Needs ``teacher``.
+ - ``opsd`` — SDFT: policy samples, demo-conditioned reverse KL against the live policy by default.
+ - ``sft`` — a frozen model samples, the policy trains with CE on its tokens. Needs ``teacher``.
+ - ``echo`` — GRPO on action tokens + weighted CE on tool-response observation tokens.
+ - ``reward`` / ``custom`` — raw-reward and user-supplied advantage functions.
+ """
+
+ model: ModelReference | None = Field(None, exclude=True, validation_alias=AliasChoices("model", "teacher"))
+ """Model reference shorthand: ``"policy"`` or an inline frozen hosted
+ model. Folds into the slot the advantage type declares for it —
+ ``advantage.model`` when the type has one (opd, opsd), ``sampling.source``
+ when the type's teacher is its sampling source (sft). A slot the user
+ didn't set takes the shorthand; an explicit reference that already equals
+ it is accepted, a disagreeing one is an error. ``teacher`` is an accepted
+ alias — the distillation algorithms declare their reference's role as
+ "teacher", and this is the slot it fills. Write-only input sugar — folded
+ by validation and excluded from dumps so resolved configs round-trip."""
+
+ sampling: SamplingConfig = SamplingConfig()
+ """Sampling component."""
+
+ advantage: AdvantageConfig = GRPOAdvantageConfig()
+ """The per-token training signal: credit assignment and loss routing,
+ fused. The ``type`` selects the algorithm."""
+
+ @property
+ def requires_group_advantage(self) -> bool:
+ """True when the advantage strategy assigns group-relative scalars,
+ i.e. degenerate with ``group_size=1``."""
+ return self.advantage.group_relative
+
+ @model_validator(mode="after")
+ def fold_model(self):
+ """Fold the ``model`` shorthand into the component references.
+
+ Fill-or-agree: the slot the advantage type declares (``model`` field,
+ or ``sampling.source`` for source-role types) takes the shorthand
+ when the user didn't set it; an explicit reference that already
+ equals it is redundant-but-consistent; if no slot accepts it, that's
+ an error."""
+ if self.model is None:
+ return self
+ matched = False
+ advantage = self.advantage
+ if "model" in type(advantage).model_fields:
+ if advantage.model is None or "model" not in advantage.model_fields_set:
+ advantage.model = self.model
+ matched = True
+ elif advantage.model == self.model:
+ matched = True
+ if getattr(advantage, "source_role", None) is not None:
+ if "source" not in self.sampling.model_fields_set:
+ self.sampling.source = self.model
+ matched = True
+ elif self.sampling.source == self.model:
+ matched = True
+ if not matched:
+ raise ValueError(
+ f"advantage '{self.advantage.type}': 'model' is set but no component reference accepts it — "
+ "every reference is already explicitly set to a different value, or the algorithm "
+ "references no model. Set advantage.model / sampling.source directly instead."
+ )
+ return self
+
+ @model_validator(mode="after")
+ def validate_component_compatibility(self):
+ source_role = getattr(self.advantage, "source_role", None)
+ if source_role is not None and self.sampling.source == "policy":
+ raise ValueError(
+ f"advantage '{self.advantage.type}' needs a {source_role} to sample rollouts from — "
+ f"CE on the policy's own tokens is not a distillation target. Set '{source_role}' on "
+ "the algorithm (an inline hosted model: name + base_url), or sampling.source explicitly."
+ )
+ if getattr(self.advantage, "model", "") is None:
+ role = getattr(self.advantage, "model_role", "reference model")
+ raise ValueError(
+ f"advantage '{self.advantage.type}' needs a {role} — "
+ f"set '{role}' on the algorithm (an inline hosted model: name + base_url), "
+ "or advantage.model explicitly."
+ )
+ if isinstance(self.advantage, OPDAdvantageConfig) and self.advantage.model == "policy":
+ raise ValueError(
+ "advantage 'opd' with model='policy' is degenerate — the reference distribution "
+ "equals the policy, so the KL signal is zero. Point at a frozen hosted model, or "
+ "use 'opsd' for demo-conditioned self-teaching."
+ )
+ if self.advantage.action_loss_type in ("rl", "ref_kl") and self.sampling.source != "policy":
+ raise ValueError(
+ f"advantage '{self.advantage.type}' trains with the "
+ f"{self.advantage.action_loss_type} loss type but sampling.source is a frozen model — "
+ "the importance ratio and trust region need the live policy's own sampling logprobs. "
+ "Use the 'sft' advantage to distill frozen-model tokens."
+ )
+ return self
+
+ def warn_group_size(self, group_size: int, env_name: str) -> None:
+ """Group-relative scoring with a single rollout per example collapses
+ every advantage to zero. Warn loudly — this is the classic footgun."""
+ if self.requires_group_advantage and group_size == 1:
+ warnings.warn(
+ f"Env '{env_name}' uses group-relative advantage ('{self.advantage.type}') with "
+ "group_size=1 — every advantage is 0 and (with the default zero-advantage filter) "
+ "no rollout will train. Set group_size >= 2 or a non-group-relative advantage "
+ "(e.g. advantage.type='reward').",
+ stacklevel=2,
+ )
diff --git a/packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py b/packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py
index 55a2210abe..fbed7d8cd4 100644
--- a/packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py
+++ b/packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py
@@ -1,3 +1,4 @@
+import copy
import math
import warnings
from pathlib import Path
@@ -7,6 +8,10 @@
from pydantic_core.core_schema import SerializerFunctionWrapHandler
from renderers import AutoRendererConfig, RendererConfig
+from prime_rl.configs.algorithm import (
+ AdvantageConfig,
+ AlgorithmConfig,
+)
from prime_rl.configs.shared import (
BaseModelConfig,
ClientConfig,
@@ -143,49 +148,6 @@ def _deprecate_max_tokens(cls, data: Any) -> Any:
return data
-class TokensLengthPenaltyConfig(BaseConfig):
- type: Literal["tokens"] = "tokens"
-
- completion_weight: float = Field(1.0, ge=0, allow_inf_nan=False)
- """Weight on model completion tokens. Finite and non-negative."""
-
- tool_response_weight: float = Field(1.0, ge=0, allow_inf_nan=False)
- """Weight on tool-response tokens (read from the rollout's ``*_total_tool_response_tokens`` harness metric; 0 if absent). Finite and non-negative."""
-
-
-class TurnsLengthPenaltyConfig(BaseConfig):
- type: Literal["turns"] = "turns"
-
-
-LengthPenaltyConfig: TypeAlias = Annotated[
- TokensLengthPenaltyConfig | TurnsLengthPenaltyConfig,
- Field(discriminator="type"),
-]
-
-
-class DefaultAdvantageConfig(BaseConfig):
- type: Literal["default"] = "default"
-
- length_penalty: LengthPenaltyConfig | None = None
- """Correctness-gated length penalty. ``tokens`` shapes by weighted token cost; ``turns`` shapes by trajectory turn count; None disables shaping. In mixed groups, lower-cost correct rollouts get amplified advantage (up to 2x), higher-cost correct rollouts are unchanged, incorrect untouched. In all-correct groups, below-average-cost rollouts get advantage in [0, 1], others get 0."""
-
-
-class CustomAdvantageConfig(BaseConfig):
- type: Literal["custom"] = "custom"
-
- import_path: str
- """Import path to the advantage function (e.g. ``my_module.my_advantage``)."""
-
- kwargs: dict[str, Any] = Field(default_factory=dict)
- """Kwargs forwarded to the advantage function."""
-
-
-AdvantageConfig: TypeAlias = Annotated[
- DefaultAdvantageConfig | CustomAdvantageConfig,
- Field(discriminator="type"),
-]
-
-
class EnvConfig(BaseConfig):
id: str = "reverse-text"
"""Registered verifiers environment ID (e.g. ``math-env``, ``primeintellect/math-env``). May include an ``@version`` suffix for installation."""
@@ -257,10 +219,17 @@ class TrainEnvConfig(EnvConfig):
"""Rollouts generated per example for GRPO group-relative advantages.
Inherits from ``orchestrator.group_size`` when unset."""
- advantage: AdvantageConfig | None = None
- """Advantage strategy for this env's GRPO groups. Inherits from the top-level
- ``orchestrator.advantage`` when unset; set a different ``default``/``custom``
- config to give this env its own advantage computation."""
+ algo: AlgorithmConfig | None = None
+ """Training algorithm for this env. Inherits from the top-level
+ ``orchestrator.algo`` when unset; set its components to give this env its
+ own algorithm."""
+
+ advantage: AdvantageConfig | None = Field(None, exclude=True)
+ """Shorthand for ``algo.advantage`` — the env assembles its own
+ algorithm around it instead of inheriting the top-level one. Setting both
+ this and an explicit ``algo.advantage`` to different values is an error.
+ Write-only input sugar — folded on raw input and excluded from dumps so
+ resolved configs round-trip."""
class EvalEnvConfig(EnvConfig):
@@ -498,21 +467,25 @@ class OrchestratorExperimentalConfig(BaseConfig):
pass
-class RolloutModelConfig(BaseConfig):
- model: ModelConfig = ModelConfig()
+class HostedModelConfig(ModelConfig):
+ """A served model reachable through an OpenAI-compatible endpoint: the
+ model fields plus the client of the live deployment."""
client: ClientConfig = ClientConfig()
class OrchestratorConfig(BaseConfig):
- training_mode: Literal["rl", "opd", "sft"] = "rl"
- """Training mode. ``rl``: student generates rollouts, no teacher. ``opd``: student generates rollouts, teacher computes logprobs (teacher_tau > 0). ``sft``: teacher generates rollouts, student inference pool used for evals and weight sync."""
-
- student: RolloutModelConfig = Field(RolloutModelConfig(), validation_alias=AliasChoices("student", "model"))
- """Student rollout participant (model + client) — the model being trained."""
-
- teacher: RolloutModelConfig | None = Field(None, validation_alias=AliasChoices("teacher", "teacher_model"))
- """Teacher rollout participant (model + client). Role depends on ``training_mode``: ``opd`` — teacher computes logprobs; ``sft`` — teacher generates rollouts."""
+ algo: AlgorithmConfig = AlgorithmConfig()
+ """Training algorithm: sampling plus the advantage (credit assignment
+ and loss routing, fused — its ``type`` names the algorithm). Defaults to
+ ``grpo``. Override per env via ``[[orchestrator.train.env]]``'s
+ ``algo``."""
+
+ model: HostedModelConfig = HostedModelConfig()
+ """The model being trained: its model fields plus the client of the live
+ vLLM deployment (``[orchestrator.model] name = ...`` with
+ ``[orchestrator.model.client]``). Algorithm components reference it as
+ ``"policy"``."""
train: TrainConfig = TrainConfig()
@@ -522,7 +495,8 @@ class OrchestratorConfig(BaseConfig):
"""Typed renderer config (``renderers.RendererConfig`` discriminated
union). Defaults to ``"auto"``, which resolves from
``tokenizer.name_or_path`` via ``MODEL_RENDERER_MAP``. ``None``
- opts into MITO (``openai_chat_completions``)."""
+ opts into MITO (``openai_chat_completions``); forced when no train env
+ samples from the policy."""
pool_size: int | None = Field(None, ge=1)
"""Number of renderer slots shared across concurrent rollouts. Bump
@@ -549,7 +523,10 @@ def _preserve_mito_renderer(self, handler: SerializerFunctionWrapHandler) -> dic
eval: EvalConfig | None = None
"""Evaluation configuration."""
- advantage: AdvantageConfig | None = DefaultAdvantageConfig()
+ advantage: AdvantageConfig | None = Field(None, exclude=True)
+ """Shorthand for ``algo.advantage`` (and, through inheritance, for envs
+ without their own algorithm). Write-only input sugar — folded on raw
+ input and excluded from dumps so resolved configs round-trip."""
pre_batch_filters: list[FilterConfig] = [
GibberishFilterConfig(enforce=False),
@@ -579,7 +556,7 @@ def _preserve_mito_renderer(self, handler: SerializerFunctionWrapHandler) -> dic
"""Collect inference-server metrics (requires wandb)."""
inference_metrics_roles: list[Literal["prefill", "decode"]] | None = None
- """Role for each student admin client when collecting P/D inference metrics."""
+ """Role for each policy admin client when collecting P/D inference metrics."""
ckpt: CheckpointConfig | None = None
"""Checkpoint configuration."""
@@ -637,63 +614,49 @@ def _preserve_mito_renderer(self, handler: SerializerFunctionWrapHandler) -> dic
@model_validator(mode="before")
@classmethod
- def fold_student_shortcuts(cls, data: Any) -> Any:
- """Accept top-level ``[orchestrator.model]`` / ``[orchestrator.client]``
- as shorthand for the student sub-config. Useful for ergonomic rl configs
- where ``[orchestrator.student.*]`` is overkill, and required for
- pre-refactor configs that used the flat layout to keep parsing:
-
- - [orchestrator.client.*] -> [orchestrator.student.client.*]
- - [orchestrator.model.] -> [orchestrator.student.model.]
- (where is any ModelConfig field)
-
- Teacher must always be configured under [orchestrator.teacher.*]
- (no equivalent shortcut), because rl mode forbids a teacher and we
- don't want the same shortcut to silently route to two different roles.
+ def fold_advantage_shortcuts(cls, data: Any) -> Any:
+ """Fold the ``advantage`` shorthands into ``algo.advantage`` on raw
+ input, before any ``AlgorithmConfig`` is built — each algorithm then
+ validates exactly once with everything in place. Defined before
+ ``_env_to_train`` (before-validators run in reverse definition order)
+ so the legacy ``[[env]]`` layout is already translated.
+
+ ``advantage = None`` (or the string ``"None"``) selects the raw-reward advantage.
"""
if not isinstance(data, dict):
return data
- def deep_merge(dst: dict, src: dict) -> None:
- """In-place recursive merge of ``src`` into ``dst``. ``src`` wins at the leaf."""
- for k, v in src.items():
- if isinstance(v, dict) and isinstance(dst.get(k), dict):
- deep_merge(dst[k], v)
- else:
- dst[k] = v
-
- # 1. Re-nest top-level [orchestrator.client] under student.client.
- legacy_client = data.pop("client", None)
- if isinstance(legacy_client, dict):
- student = data.setdefault("student", {})
- if isinstance(student, dict):
- deep_merge(student.setdefault("client", {}), legacy_client)
- else:
- # Mismatched types - put it back and let pydantic surface the error.
- data["client"] = legacy_client
-
- # 2. Consolidate the legacy `model` alias into `student` so the
- # flat-layout fix-up below sees a single target. Deep-merge with the
- # legacy keys winning so a CLI `--model.` overrides TOML `student.model.`.
- legacy_model = data.pop("model", None)
- if legacy_model is not None:
- existing = data.get("student")
- if existing is None:
- data["student"] = legacy_model
- elif isinstance(existing, dict) and isinstance(legacy_model, dict):
- deep_merge(existing, legacy_model)
- else:
- # Mismatched types - put it back and let pydantic surface the error.
- data["model"] = legacy_model
+ def fold(algo: Any, shorthand: Any, owner: str) -> None:
+ if not isinstance(algo, dict):
+ raise ValueError(
+ f"{owner}: the 'advantage' shorthand needs 'algo' as plain config data — "
+ "set 'algo.advantage' directly instead."
+ )
+ if shorthand is None or shorthand == "None":
+ shorthand = {"type": "reward"}
+ existing = algo.get("advantage")
+ if existing is not None and existing != shorthand:
+ raise ValueError(
+ f"{owner}: 'advantage' shorthand conflicts with the explicit 'algo.advantage'. Set one."
+ )
+ algo["advantage"] = copy.deepcopy(shorthand)
- # 3. Re-nest flat ModelConfig keys under student.model.
- model_only_keys = set(ModelConfig.model_fields)
- student = data.get("student")
- if isinstance(student, dict):
- flat = {k: student.pop(k) for k in list(student) if k in model_only_keys}
- if flat:
- student.setdefault("model", {}).update(flat)
+ if "advantage" in data:
+ fold(data.setdefault("algo", {}), data["advantage"], "orchestrator")
+ train = data.get("train")
+ envs = train.get("env") if isinstance(train, dict) else None
+ if not isinstance(envs, list):
+ return data
+ for env in envs:
+ if not isinstance(env, dict) or "advantage" not in env:
+ continue
+ # The shorthand makes the env assemble its own algorithm instead
+ # of inheriting-and-modifying the top-level one.
+ if env.get("algo") is None:
+ env["algo"] = {}
+ name = env.get("name") or str(env.get("id", "?")).split("@")[0]
+ fold(env["algo"], env["advantage"], f"env '{name}'")
return data
@model_validator(mode="before")
@@ -726,15 +689,15 @@ def _env_to_train(cls, data: Any) -> Any:
@model_validator(mode="after")
def auto_setup_tokenizer(self):
if self.tokenizer.name is None:
- self.tokenizer.name = self.student.model.name
+ self.tokenizer.name = self.model.name
if self.tokenizer.trust_remote_code is None:
- self.tokenizer.trust_remote_code = self.student.model.trust_remote_code
+ self.tokenizer.trust_remote_code = self.model.trust_remote_code
return self
@model_validator(mode="after")
def auto_setup_session_headers(self):
"""Ensure X-Session-ID header is always set for sticky DP-aware routing at the inference router."""
- self.student.client.extra_headers_from_state.setdefault("X-Session-ID", "trajectory_id")
+ self.model.client.extra_headers_from_state.setdefault("X-Session-ID", "trajectory_id")
return self
@model_validator(mode="after")
@@ -758,35 +721,62 @@ def validate_unique_filter_types(self):
return self
@model_validator(mode="after")
- def _force_no_renderer_for_sft(self):
- """Teacher-backed SFT rolls out via the teacher's plain chat-completions
- endpoint; the renderer client doesn't apply. When no teacher is
- configured, SFT uses the student rollout path and keeps the renderer."""
- if self.training_mode == "sft" and self.teacher is not None:
- self.renderer = None
+ def inherit_env_algorithms(self):
+ """Envs without their own algorithm inherit the top-level one (the
+ ``advantage`` shorthands are already folded in on raw input by
+ ``fold_advantage_shortcuts``). Declared before any validator that
+ reads ``algo``."""
+ for env_cfg in self.train.env:
+ if env_cfg.algo is None:
+ env_cfg.algo = self.algo.model_copy(deep=True)
return self
+ @property
+ def any_policy_sourced(self) -> bool:
+ """True when at least one train env samples rollouts from the live policy."""
+ return any(env.algo is not None and env.algo.sampling.source == "policy" for env in self.train.env)
+
@model_validator(mode="after")
- def validate_training_mode(self):
- """Enforce training mode invariants that involve only orchestrator fields."""
- has_teacher = self.teacher is not None
- if self.training_mode == "rl" and has_teacher:
- raise ValueError("orchestrator.teacher must not be set when training_mode = 'rl'.")
- if self.training_mode == "opd" and not has_teacher:
- raise ValueError("orchestrator.teacher must be configured when training_mode = 'opd'.")
+ def validate_renderer_for_demo_scoring(self):
+ """``opsd`` rebuilds its demo-conditioned scoring prefix
+ client-side, which requires the policy's renderer (the canonical
+ messages → token ids path)."""
+ if self.renderer is not None:
+ return self
+ for env in self.train.env:
+ if env.algo is not None and env.algo.advantage.type == "opsd":
+ raise ValueError(
+ f"env '{env.resolved_name}' uses opsd, which renders its demo-conditioned "
+ "scoring prefix client-side and requires orchestrator.renderer — remove "
+ "'renderer = \"None\"'."
+ )
+ if env.algo is not None and env.algo.advantage.type == "echo":
+ raise ValueError(
+ f"env '{env.resolved_name}' trains env-provided tokens by message role (echo), "
+ "which needs the renderer's per-token attribution — set orchestrator.renderer."
+ )
return self
@model_validator(mode="after")
def validate_pool_size(self):
- """``pool_size`` is only meaningful when the renderer is enabled
- (``renderer is not None``). Reject otherwise so callers don't
- silently pass it and wonder why it's ignored."""
- if self.renderer is None and self.pool_size is not None:
+ """``pool_size`` sizes the renderer-client pool for policy-sourced
+ sampling. Reject it when that path never runs — no renderer, or no
+ train env samples from the policy — so callers don't silently pass
+ it and wonder why it's ignored."""
+ if self.pool_size is None:
+ return self
+ if self.renderer is None:
raise ValueError(
f"orchestrator.pool_size={self.pool_size!r} is set but "
"orchestrator.renderer is None (MITO mode). Either configure a renderer "
"or remove pool_size."
)
+ if not self.any_policy_sourced:
+ raise ValueError(
+ f"orchestrator.pool_size={self.pool_size!r} is set but no train env samples "
+ "from the policy — the renderer-client sampling pool never runs (the renderer "
+ "is still used for client-side tokenization). Remove pool_size."
+ )
return self
@model_validator(mode="after")
@@ -797,7 +787,7 @@ def vlm_requires_renderer(self):
tokens, and ships generic ``mm_kwargs`` keyed by whatever the
model's forward signature expects.
"""
- if self.student.model.vlm is not None and self.renderer is None:
+ if self.model.vlm is not None and self.renderer is None:
raise ValueError(
"orchestrator.renderer must be set when model.vlm is set. "
"VLMs must go through a renderer (e.g. Qwen3VLRenderer) that owns the processor."
@@ -821,7 +811,7 @@ def validate_renderer_auto_resolves(self):
return self
from renderers.base import MODEL_RENDERER_MAP
- model_id = self.tokenizer.name or self.student.model.name
+ model_id = self.tokenizer.name or self.model.name
if model_id in MODEL_RENDERER_MAP:
return self
raise ValueError(
@@ -879,11 +869,8 @@ def resolve_batching(self):
for env_cfg in self.train.env:
if "group_size" not in env_cfg.model_fields_set:
env_cfg.group_size = self.group_size
-
- # Propagate the top-level ``advantage`` into each train env that didn't set its own.
- for env_cfg in self.train.env:
- if "advantage" not in env_cfg.model_fields_set:
- env_cfg.advantage = self.advantage
+ assert env_cfg.algo is not None # materialized by inherit_env_algorithms
+ env_cfg.algo.warn_group_size(env_cfg.group_size, env_cfg.resolved_name)
# Resolve train env num_workers from max_inflight_rollouts
for env_cfg in self.train.env:
@@ -910,10 +897,12 @@ def auto_setup_bench(self):
@model_validator(mode="after")
def resolve_env_config(self):
"""Populate extra_env_kwargs and vLLM sampling defaults from top-level fields."""
- is_vllm = self.training_mode != "sft"
for env in self.train.env:
env.extra_env_kwargs.update(max_seq_len=self.seq_len)
- if is_vllm:
+ # Policy-sourced rollouts hit our vLLM server; frozen-sourced
+ # rollouts may hit external OAI endpoints that reject these knobs.
+ assert env.algo is not None
+ if env.algo.sampling.source == "policy":
env.sampling.extra_body.setdefault("top_k", -1)
env.sampling.extra_body.setdefault("min_p", 0.0)
env.sampling.extra_body.setdefault("return_token_ids", True)
diff --git a/packages/prime-rl-configs/src/prime_rl/configs/rl.py b/packages/prime-rl-configs/src/prime_rl/configs/rl.py
index 832234eea9..353266c791 100644
--- a/packages/prime-rl-configs/src/prime_rl/configs/rl.py
+++ b/packages/prime-rl-configs/src/prime_rl/configs/rl.py
@@ -373,40 +373,40 @@ def auto_setup_lora(self):
if self.trainer.weight_broadcast.type == "nccl":
raise ValueError("NCCL weight broadcast does not support LoRA yet.")
- if self.orchestrator.student.model.lora is None:
+ if self.orchestrator.model.lora is None:
from prime_rl.configs.orchestrator import LoRAConfig
- self.orchestrator.student.model.lora = LoRAConfig()
+ self.orchestrator.model.lora = LoRAConfig()
if (
- self.orchestrator.student.model.lora.rank is not None
- and self.orchestrator.student.model.lora.rank != self.trainer.model.lora.rank
+ self.orchestrator.model.lora.rank is not None
+ and self.orchestrator.model.lora.rank != self.trainer.model.lora.rank
):
raise ValueError(
- f"orchestrator.student.model.lora.rank ({self.orchestrator.student.model.lora.rank}) conflicts with "
+ f"orchestrator.model.lora.rank ({self.orchestrator.model.lora.rank}) conflicts with "
f"trainer.model.lora.rank ({self.trainer.model.lora.rank}). "
- f"Remove orchestrator.student.model.lora.rank to inherit from trainer, or update trainer.model.lora.rank to match."
+ f"Remove orchestrator.model.lora.rank to inherit from trainer, or update trainer.model.lora.rank to match."
)
if (
- self.orchestrator.student.model.lora.alpha is not None
- and self.orchestrator.student.model.lora.alpha != self.trainer.model.lora.alpha
+ self.orchestrator.model.lora.alpha is not None
+ and self.orchestrator.model.lora.alpha != self.trainer.model.lora.alpha
):
raise ValueError(
- f"orchestrator.student.model.lora.alpha ({self.orchestrator.student.model.lora.alpha}) conflicts with "
+ f"orchestrator.model.lora.alpha ({self.orchestrator.model.lora.alpha}) conflicts with "
f"trainer.model.lora.alpha ({self.trainer.model.lora.alpha}). "
- f"Remove orchestrator.student.model.lora.alpha to inherit from trainer, or update trainer.model.lora.alpha to match."
+ f"Remove orchestrator.model.lora.alpha to inherit from trainer, or update trainer.model.lora.alpha to match."
)
- if self.orchestrator.student.model.lora.rank is None:
- self.orchestrator.student.model.lora.rank = self.trainer.model.lora.rank
+ if self.orchestrator.model.lora.rank is None:
+ self.orchestrator.model.lora.rank = self.trainer.model.lora.rank
- if self.orchestrator.student.model.lora.alpha is None:
- self.orchestrator.student.model.lora.alpha = self.trainer.model.lora.alpha
+ if self.orchestrator.model.lora.alpha is None:
+ self.orchestrator.model.lora.alpha = self.trainer.model.lora.alpha
- if self.orchestrator.student.model.lora.name is None:
- self.orchestrator.student.model.lora.name = (
- f"r{self.orchestrator.student.model.lora.rank}-a{self.orchestrator.student.model.lora.alpha}"
+ if self.orchestrator.model.lora.name is None:
+ self.orchestrator.model.lora.name = (
+ f"r{self.orchestrator.model.lora.rank}-a{self.orchestrator.model.lora.alpha}"
)
if self.inference is not None:
@@ -604,19 +604,20 @@ def auto_setup_disaggregated_inference(self):
@model_validator(mode="after")
def auto_setup_inference_client(self):
- """Auto-configure orchestrator student client from the inference server config.
+ """Auto-configure the orchestrator policy client from the inference server config.
- For all modes, sets dp_rank_count from inference DP size. For SFT mode,
- also sets base_url - rl/opd rely on the ClientConfig default
+ Always sets dp_rank_count from inference DP size. When no train env
+ samples from the policy (e.g. sft_distill), also sets base_url —
+ policy-sourced algorithms rely on the ClientConfig default
(``["http://localhost:8000/v1"]``) which already matches the auto-launched
- student vLLM at inference.server.port = 8000.
+ policy vLLM at inference.server.port = 8000.
"""
if self.inference is None:
return self
- client = self.orchestrator.student.client
+ client = self.orchestrator.model.client
if "dp_rank_count" not in client.model_fields_set:
client.dp_rank_count = self.inference.data_parallel_size_local or self.inference.parallel.dp
- if self.orchestrator.training_mode == "sft" and "base_url" not in client.model_fields_set:
+ if not self.orchestrator.any_policy_sourced and "base_url" not in client.model_fields_set:
host = self.inference.server.host or "localhost"
port = self.inference.server.port
client.base_url = [f"http://{host}:{port}/v1"]
diff --git a/packages/prime-rl-configs/src/prime_rl/configs/trainer.py b/packages/prime-rl-configs/src/prime_rl/configs/trainer.py
index f1522d4efe..14ac89b342 100644
--- a/packages/prime-rl-configs/src/prime_rl/configs/trainer.py
+++ b/packages/prime-rl-configs/src/prime_rl/configs/trainer.py
@@ -510,7 +510,7 @@ class TrainerConfig(BaseConfig):
data: DataLoaderConfig = DataLoaderConfig()
loss: LossConfig = DefaultLossConfig()
- """Loss config for rl-mode batches. opd and sft batches dispatch to their own loss fns unconditionally and do not read this."""
+ """Loss config for the rl loss component (see ``setup_rl_loss_fn``). The ce / ref_kl components are fixed and do not read this."""
optim: OptimizerConfig = AdamWConfig()
diff --git a/packages/prime-rl-configs/src/prime_rl/utils/validation.py b/packages/prime-rl-configs/src/prime_rl/utils/validation.py
index 1249061bd0..aa34f8c3e7 100644
--- a/packages/prime-rl-configs/src/prime_rl/utils/validation.py
+++ b/packages/prime-rl-configs/src/prime_rl/utils/validation.py
@@ -22,8 +22,9 @@ def propagate_shared_fields(data: Any) -> Any:
The original footgun the mutex was designed to catch — a sub-config
value silently winning over a later CLI shared override — is still
caught because that scenario produces *different* values.
- - **Aliased sub-paths**: ``orchestrator.model.*`` is checked against its
- ``orchestrator.student.model.*`` alias (and vice versa), so the
+ - **Aliased sub-paths**: ``orchestrator.model.*`` (flat) is checked
+ against the nested ``orchestrator.model.*`` spelling and the
+ ``orchestrator.policy.*`` / ``orchestrator.student.*`` aliases, so the
conflict fires regardless of which spelling the user wrote.
"""
if not isinstance(data, dict):
@@ -67,20 +68,29 @@ def propagate(shared_path: str, *targets: str, aliases: tuple[str, ...] = ()) ->
for target in targets:
fill(target, value)
- # [model] → trainer / orchestrator (student, via AliasChoices) / inference.
+ # [model] → trainer / orchestrator (flat spelling, re-nested by
+ # fold_policy_shortcuts) / inference.
propagate(
"model.name",
"trainer.model.name",
"inference.model.name",
"orchestrator.model.name",
- aliases=("orchestrator.student.model.name",),
+ aliases=(
+ "orchestrator.model.name",
+ "orchestrator.policy.model.name",
+ "orchestrator.student.model.name",
+ ),
)
propagate(
"model.vlm",
"trainer.model.vlm",
"inference.model.vlm",
"orchestrator.model.vlm",
- aliases=("orchestrator.student.model.vlm",),
+ aliases=(
+ "orchestrator.model.vlm",
+ "orchestrator.policy.model.vlm",
+ "orchestrator.student.model.vlm",
+ ),
)
# [log]
@@ -224,18 +234,18 @@ def validate_shared_model_name(
) -> None:
# Orchestrator must match inference (it queries the inference server)
if inference is not None:
- if inference.model.name != orchestrator.student.model.name:
+ if inference.model.name != orchestrator.model.name:
raise ValueError(
- f"Inference model name ({inference.model.name}) and orchestrator model name ({orchestrator.student.model.name}) are not the same. "
+ f"Inference model name ({inference.model.name}) and orchestrator model name ({orchestrator.model.name}) are not the same. "
"The orchestrator queries the inference server and must use the same model name."
)
return
if trainer.model.name.startswith("Jackmin108/"): # The TT MoE models will have a different name on the orchestrator
return
- if trainer.model.name != orchestrator.student.model.name:
+ if trainer.model.name != orchestrator.model.name:
raise ValueError(
- f"Trainer model name ({trainer.model.name}) and orchestrator model name ({orchestrator.student.model.name}) are not the same. Please specify the same model name for both."
+ f"Trainer model name ({trainer.model.name}) and orchestrator model name ({orchestrator.model.name}) are not the same. Please specify the same model name for both."
)
diff --git a/skills/configs/SKILL.md b/skills/configs/SKILL.md
index 264cf97df7..0321033da9 100644
--- a/skills/configs/SKILL.md
+++ b/skills/configs/SKILL.md
@@ -47,9 +47,11 @@ id = "reverse-text"
CLI: `--env.0.id reverse-text --env.1.id math-env`.
-**Dicts** — TOML uses a section; CLI takes a JSON string: `--vllm-extra '{"key1": "value1"}'`.
+**Dicts** — TOML uses a section; CLI takes a JSON string: `--vllm-extra '{"key1": "value1"}'`. This works for plain `dict` fields only — nested pydantic-model fields (e.g. `advantage`) reject JSON strings; use dotted keys (`--orchestrator.algo.advantage.type custom`) or a TOML overlay file.
-**Discriminated unions** — set the `type` field to pick the variant (`[trainer.loss] type = "sft"`). Omit `type` to keep the default variant.
+**Discriminated unions** — set the `type` field to pick the variant (`[orchestrator.advantage] type = "max_rl"`). Omit `type` to keep the default variant.
+
+**Algorithms** — `[orchestrator.algo.advantage] type = "grpo" | "max_rl" | "opd" | "opsd" | "sft" | "echo" | "reward" | "custom"` — the advantage type names the algorithm (credit assignment + loss routing, fused), and each type's class defaults are its vetted setting; any other key you set is your own assembly (e.g. `[orchestrator.algo.advantage.roles.user] alpha = 0.1` for echo — setting any echo role replaces the whole role table). There is no preset layer. Per-env override: `[[orchestrator.train.env]]` `advantage = { type = "echo" }` (the env assembles its own algorithm). prime-rl only hosts the trainable policy; frozen models are inline external endpoints on the algorithm — `[orchestrator.algo.teacher]` (alias for `model`) with `name` + `base_url` folds into the slot the type declares (`advantage.model` for opd/opsd, `sampling.source` for sft). `model = "policy"` points a component at the live policy (opsd's default). See `docs/algorithms.md`.
**`BaseModel | None` fields** — bare flag enables defaults; nested override enables and sets:
@@ -62,7 +64,7 @@ In TOML, an empty section header (`[ckpt]`) does the same.
## RL trainer token exports
-For rollout debugging, enable trainer-side token export with `trainer.enable_token_export = true` (or `--enable-token-export` when running the trainer entrypoint directly). It writes one JSONL record per exported sequence. Single-run/fallback exports go under `output_dir/token_exports/step_/rank_.jsonl`; multi-run trainer exports with packer metadata go under the owning run directory, `output_dir//token_exports/step_/rank_.jsonl`. Each record stores aligned per-token arrays for token ids, loss mask, advantage, reward, entropy, mismatch KL, inference/trainer logprobs, importance ratios, probability deltas, and masking diagnostics. It does not decode token text in the trainer.
+For rollout debugging, enable trainer-side token export with `trainer.enable_token_export = true` (or `--enable-token-export` when running the trainer entrypoint directly). It writes one JSONL record per exported sequence. Single-run/fallback exports go under `output_dir/token_exports/step_/rank_.jsonl`; multi-run trainer exports with packer metadata go under the owning run directory, `output_dir//token_exports/step_/rank_.jsonl`. Each record stores aligned per-token arrays for token ids, loss mask, component weight streams (rl/ce/ref_kl), advantage, reward, entropy, mismatch KL, inference/trainer logprobs, importance ratios, probability deltas, and masking diagnostics. It does not decode token text in the trainer.
```toml
enable_token_export = true
diff --git a/src/prime_rl/entrypoints/rl.py b/src/prime_rl/entrypoints/rl.py
index db1e27b995..a7b85621ca 100644
--- a/src/prime_rl/entrypoints/rl.py
+++ b/src/prime_rl/entrypoints/rl.py
@@ -12,6 +12,7 @@
import pynvml
import tomli_w
+from prime_rl.configs.algorithm import FrozenModelConfig
from prime_rl.configs.rl import RLConfig
from prime_rl.utils.config import cli
from prime_rl.utils.logger import get_logger, setup_logger
@@ -116,16 +117,16 @@ def rl_local(config: RLConfig):
}
# Validate client port matches inference server port
- if config.inference is not None and not config.orchestrator.student.client.is_elastic:
+ if config.inference is not None and not config.orchestrator.model.client.is_elastic:
from urllib.parse import urlparse
- base_url = config.orchestrator.student.client.base_url[0]
+ base_url = config.orchestrator.model.client.base_url[0]
parsed = urlparse(base_url)
client_port = parsed.port
expected_port = config.inference.server.port
if client_port != expected_port:
raise ValueError(
- f"orchestrator.student.client.base_url port ({client_port}) does not match "
+ f"orchestrator.model.client.base_url port ({client_port}) does not match "
f"inference.server.port ({expected_port}). "
f"Update the base_url to use port {expected_port} to match the inference server."
)
@@ -179,19 +180,27 @@ def sigterm_handler(signum, frame):
monitor_threads.append(monitor_thread)
else:
logger.warning(
- "No [inference] block configured - the student inference server will not be started here. "
- "All training modes (rl/opd/sft) require a student inference pool for evals + weight sync; "
- "make sure one is running at orchestrator.student.client.base_url "
- f"({', '.join(config.orchestrator.student.client.base_url)}), otherwise the orchestrator "
+ "No [inference] block configured - the policy inference server will not be started here. "
+ "Every algorithm requires a policy inference pool for evals + weight sync; "
+ "make sure one is running at orchestrator.model.client.base_url "
+ f"({', '.join(config.orchestrator.model.client.base_url)}), otherwise the orchestrator "
"will hang waiting for it."
)
- if config.orchestrator.teacher:
+ frozen_endpoints: list[str] = []
+ for env in config.orchestrator.train.env:
+ algo = env.algo
+ if algo is None:
+ continue
+ for ref in (algo.sampling.source, getattr(algo.advantage, "model", None)):
+ if isinstance(ref, FrozenModelConfig):
+ frozen_endpoints.append(f"{ref.name} ({', '.join(ref.base_url)})")
+ if frozen_endpoints:
+ endpoints = ", ".join(dict.fromkeys(frozen_endpoints))
logger.info(
- "orchestrator.teacher is configured - the rl entrypoint does not start teacher inference "
- "servers. Make sure your teacher endpoint at "
- f"{', '.join(config.orchestrator.teacher.client.base_url)} is running before the "
- "orchestrator starts, otherwise rollouts will hang."
+ "Frozen model references are configured - the rl entrypoint does not start them. "
+ f"Make sure these endpoints are serving before the orchestrator starts: {endpoints}; "
+ "otherwise rollouts will hang."
)
orchestrator_cmd = ["orchestrator", "@", (config_dir / ORCHESTRATOR_TOML).as_posix()]
diff --git a/src/prime_rl/orchestrator/advantage.py b/src/prime_rl/orchestrator/advantage.py
deleted file mode 100644
index b58a410326..0000000000
--- a/src/prime_rl/orchestrator/advantage.py
+++ /dev/null
@@ -1,147 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import TYPE_CHECKING, Callable
-
-import torch
-import verifiers as vf
-from jaxtyping import Float
-from torch import Tensor
-
-if TYPE_CHECKING:
- from prime_rl.orchestrator.types import TrainRollout
-
-from prime_rl.configs.orchestrator import (
- AdvantageConfig,
- CustomAdvantageConfig,
- LengthPenaltyConfig,
- TokensLengthPenaltyConfig,
- TurnsLengthPenaltyConfig,
-)
-from prime_rl.orchestrator.utils import get_model_completion_len, get_tool_response_len
-from prime_rl.utils.utils import import_object
-
-
-@dataclass
-class AdvantageInputs:
- """Inputs for advantage computation of a single group (one example × N rollouts)."""
-
- rollouts: list[vf.RolloutOutput]
-
-
-@dataclass
-class AdvantageOutputs:
- """Outputs from advantage computation of a single group."""
-
- advantages: list[float]
-
-
-AdvantageFn = Callable[..., AdvantageOutputs]
-"""Type for an advantage function.
-
-Expected signature:
- def my_advantage(inputs: AdvantageInputs, **kwargs) -> AdvantageOutputs:
- ...
-
-The function receives a single group and returns a list of advantages with one
-entry per rollout. `assign_advantages` calls it on one already-grouped cohort.
-"""
-
-
-def default_advantage_fn(
- inputs: AdvantageInputs,
- length_penalty: LengthPenaltyConfig | None = None,
-) -> AdvantageOutputs:
- """Default GRPO advantage for a single group: reward minus per-group baseline.
-
- `length_penalty` enables correctness-gated efficiency shaping over a per-rollout
- cost: tokens (weighted completion + tool-response) or trajectory turn count.
- """
- rewards = torch.tensor([r["reward"] for r in inputs.rollouts], dtype=torch.float32)
-
- if isinstance(length_penalty, TokensLengthPenaltyConfig):
- w_c = length_penalty.completion_weight
- w_t = length_penalty.tool_response_weight
- costs = torch.tensor(
- [w_c * get_model_completion_len(r) + w_t * get_tool_response_len(r) for r in inputs.rollouts],
- dtype=rewards.dtype,
- )
- return AdvantageOutputs(advantages=_efficiency_shaping(rewards, costs).tolist())
- if isinstance(length_penalty, TurnsLengthPenaltyConfig):
- costs = torch.tensor([len(r["trajectory"]) for r in inputs.rollouts], dtype=rewards.dtype)
- return AdvantageOutputs(advantages=_efficiency_shaping(rewards, costs).tolist())
-
- return AdvantageOutputs(advantages=(rewards - rewards.mean()).tolist())
-
-
-def _efficiency_shaping(
- rewards: Float[Tensor, "group_size"],
- costs: Float[Tensor, "group_size"],
-) -> Float[Tensor, "group_size"]:
- """Correctness-gated efficiency shaping with bounded advantages.
-
- Shapes rewards with a bounded efficiency bonus before standard GRPO subtraction,
- preserving zero-mean advantages within the group. `costs` is a per-rollout cost
- (e.g., completion length in tokens or number of turns).
-
- Correct rollouts get reward amplified by up to 2x based on relative efficiency.
- Incorrect rollouts are untouched. Lower-cost correct rollouts get higher advantage.
- """
- max_reward = rewards.max()
- correct_mask = rewards >= max_reward
- num_correct = correct_mask.sum()
-
- # No shaping when max reward is 0 — no correct rollouts to differentiate
- if max_reward <= 0:
- return rewards - rewards.mean()
-
- # Mean cost of correct rollouts
- mean_correct_cost = (costs * correct_mask).sum() / num_correct.clamp(min=1)
-
- # Bounded efficiency bonus: [0, 1], positive for below-average cost, zero for above.
- # When mean_correct_cost is 0 (e.g. tool-only shaping with no harness metric, or
- # all-zero turn counts), no rollouts can be differentiated — fall back to no bonus.
- if mean_correct_cost <= 0:
- return rewards - rewards.mean()
-
- bonus = (1 - costs / mean_correct_cost).clamp(0, 1)
-
- # Shape rewards: correct rollouts amplified by up to 2x, incorrect untouched
- shaped_rewards = rewards * (1 + bonus * correct_mask)
- return shaped_rewards - shaped_rewards.mean()
-
-
-def setup_advantage_fn(config: AdvantageConfig) -> AdvantageFn:
- """Setup advantage function from config."""
- if isinstance(config, CustomAdvantageConfig):
- custom_fn = import_object(config.import_path)
- kwargs = config.kwargs
-
- def advantage_fn(inputs: AdvantageInputs) -> AdvantageOutputs:
- return custom_fn(inputs, **kwargs)
-
- return advantage_fn
-
- def advantage_fn(inputs: AdvantageInputs) -> AdvantageOutputs:
- return default_advantage_fn(inputs, length_penalty=config.length_penalty)
-
- return advantage_fn
-
-
-def assign_advantages(
- rollouts: list["TrainRollout"], # noqa: F821 (forward ref)
- advantage_fn: AdvantageFn | None,
-) -> None:
- """Compute and assign advantages for one finished group of rollouts
- (``TrainSink.process_group`` hands in a single group's surviving rollouts).
- ``advantage_fn=None`` is the trivial case (advantage = reward); a custom
- ``advantage_fn`` receives the raw ``vf.RolloutOutput``\\ s via
- ``AdvantageInputs.rollouts``.
- """
- if advantage_fn is None:
- for rollout in rollouts:
- rollout.advantage = rollout.reward
- return
- result = advantage_fn(AdvantageInputs(rollouts=[r.raw for r in rollouts]))
- for rollout, advantage in zip(rollouts, result.advantages):
- rollout.advantage = advantage
diff --git a/src/prime_rl/orchestrator/algo/__init__.py b/src/prime_rl/orchestrator/algo/__init__.py
new file mode 100644
index 0000000000..0c799dae98
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/__init__.py
@@ -0,0 +1,120 @@
+"""Orchestrator-side algorithm runtime.
+
+The config side (``prime_rl.configs.algorithm``) defines *what* an algorithm
+is — a bundle of sampling and the per-token training signal. This package
+turns the signal half into runtime objects (the sampling half is the env's
+:class:`~prime_rl.orchestrator.sampler.Sampler`):
+
+- one module per algorithm (``grpo``, ``echo``, ``max_rl``, ``opd``,
+ ``opsd``, ``sft``, ``reward``, ``custom``) — each named class owns its
+ scoring hooks (``score_rollout`` / ``score_group`` / ``score_batch``) and
+ declares what it needs (loss component, a "teacher", ...). One instance per
+ env, built by :func:`build_algorithm`. Custom credit assignment plugs in
+ through the ``custom`` advantage type (:class:`CustomAlgorithm` imports a
+ user function by path).
+- ``base`` — the :class:`Algorithm` base class and the pipeline phase
+ functions (:func:`finalize_rollout` / :func:`finalize_group` /
+ :func:`finalize_batch`).
+- ``advantage`` — pure advantage math (default group-norm + the
+ custom-function interface). Advantages are per-token everywhere they are
+ stored or shipped — there is no scalar advantage in the pipeline. A
+ function takes ``RolloutView`` objects and returns one value per rollout: a
+ scalar that the view *broadcasts* over the rollout's completion tokens
+ (uniform credit, the common case), or an explicit per-token list.
+- ``routing`` — wire-field stamping: per-token component weight streams
+ (rl / ce / ref_kl) and the per-token advantage stream.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prime_rl.orchestrator.algo.advantage import (
+ AdvantageFn,
+ apply_advantage_fn,
+ efficiency_shaping_advantage,
+ grpo_advantage,
+ length_penalty_advantage,
+ max_rl_advantage_fn,
+)
+from prime_rl.orchestrator.algo.base import (
+ Algorithm,
+ connect_frozen_pool,
+ finalize_batch,
+ finalize_group,
+ finalize_rollout,
+)
+from prime_rl.orchestrator.algo.custom import CustomAlgorithm
+from prime_rl.orchestrator.algo.echo import EchoAlgorithm
+from prime_rl.orchestrator.algo.grpo import GRPOAlgorithm
+from prime_rl.orchestrator.algo.max_rl import MaxRLAlgorithm
+from prime_rl.orchestrator.algo.opd import OPDAlgorithm
+from prime_rl.orchestrator.algo.opsd import OPSDAlgorithm
+from prime_rl.orchestrator.algo.reward import RewardAlgorithm
+from prime_rl.orchestrator.algo.routing import stamp_advantages, stamp_loss_routing
+from prime_rl.orchestrator.algo.sft import SFTDistillAlgorithm
+from prime_rl.orchestrator.types import RolloutView
+
+if TYPE_CHECKING:
+ from renderers.base import Renderer
+
+ from prime_rl.configs.algorithm import AlgorithmConfig
+ from prime_rl.utils.client import InferencePool
+
+# Runtime dispatch is keyed on the advantage type — it names the algorithm,
+# and each config class's defaults are its vetted parameterization.
+ALGORITHM_CLASSES: dict[str, type[Algorithm]] = {
+ "grpo": GRPOAlgorithm,
+ "echo": EchoAlgorithm,
+ "max_rl": MaxRLAlgorithm,
+ "opd": OPDAlgorithm,
+ "opsd": OPSDAlgorithm,
+ "sft": SFTDistillAlgorithm,
+ "reward": RewardAlgorithm,
+ "custom": CustomAlgorithm,
+}
+
+
+def build_algorithm(
+ config: AlgorithmConfig,
+ policy_pool: InferencePool,
+ renderer: Renderer | None,
+ max_seq_len: int | None = None,
+) -> Algorithm:
+ cls = ALGORITHM_CLASSES[config.advantage.type]
+ assert cls.action_loss_type == config.advantage.action_loss_type # config and runtime declare in two places
+ # The bundle dissolves at construction: the Algorithm is the advantage
+ # component's runtime (its sibling Sampler interprets the sampling half).
+ algorithm = cls(config.advantage, policy_pool, renderer)
+ # Host resource the constructor contract doesn't carry — only the GRPO
+ # linear length penalty reads it, so it's injected rather than threaded
+ # through every algorithm's __init__.
+ algorithm.max_seq_len = max_seq_len
+ return algorithm
+
+
+__all__ = [
+ "AdvantageFn",
+ "Algorithm",
+ "CustomAlgorithm",
+ "EchoAlgorithm",
+ "GRPOAlgorithm",
+ "MaxRLAlgorithm",
+ "OPDAlgorithm",
+ "OPSDAlgorithm",
+ "RewardAlgorithm",
+ "RolloutView",
+ "SFTDistillAlgorithm",
+ "apply_advantage_fn",
+ "build_algorithm",
+ "connect_frozen_pool",
+ "efficiency_shaping_advantage",
+ "finalize_batch",
+ "finalize_group",
+ "finalize_rollout",
+ "grpo_advantage",
+ "length_penalty_advantage",
+ "max_rl_advantage_fn",
+ "stamp_advantages",
+ "stamp_loss_routing",
+]
diff --git a/src/prime_rl/orchestrator/algo/advantage.py b/src/prime_rl/orchestrator/algo/advantage.py
new file mode 100644
index 0000000000..2039f416e9
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/advantage.py
@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Callable
+
+import torch
+from jaxtyping import Float
+from torch import Tensor
+
+if TYPE_CHECKING:
+ from prime_rl.orchestrator.types import RolloutView
+
+from prime_rl.configs.algorithm import (
+ LinearLengthPenaltyConfig,
+ TokensLengthPenaltyConfig,
+ TurnsLengthPenaltyConfig,
+)
+from prime_rl.orchestrator.utils import get_model_completion_len, get_tool_response_len
+
+AdvantageFn = Callable[..., list[float | list[float]]]
+"""Type for an advantage function.
+
+Expected signature:
+ def my_advantage(group: list[RolloutView], **kwargs) -> list[float | list[float]]:
+ ...
+
+The function receives one finalized group — the same ``RolloutView``\\ s the
+``score_group`` hook sees (``raw`` in step coordinates, ``samples`` in merged
+token coordinates) — and returns one value per rollout: a scalar (broadcast
+over the rollout's completion tokens) or a per-token list aligned to them.
+`apply_advantage_fn` writes each through ``RolloutView.assign_advantages``.
+"""
+
+
+def grpo_advantage(group: list["RolloutView"], length_weighted_baseline: bool = False) -> list[float]:
+ """Plain GRPO advantage for a single group: reward minus the per-group
+ baseline (DR-GRPO without std normalization).
+
+ ``length_weighted_baseline`` uses the token-length-weighted mean reward
+ (``sum(len_i * reward_i) / sum(len_i)``) as the baseline instead of the plain
+ mean, centering advantages by per-token expected reward.
+ """
+ rewards = torch.tensor([v.reward for v in group], dtype=torch.float32)
+ if length_weighted_baseline:
+ lengths = torch.tensor([get_model_completion_len(v.raw) for v in group], dtype=rewards.dtype)
+ baseline = (lengths * rewards).sum() / lengths.sum()
+ else:
+ baseline = rewards.mean()
+ return (rewards - baseline).tolist()
+
+
+def length_penalty_advantage(
+ group: list["RolloutView"],
+ config: LinearLengthPenaltyConfig,
+ max_seq_len: int | None,
+ length_weighted_baseline: bool = False,
+) -> list[float]:
+ """The linear length penalty as a standalone additive advantage term.
+
+ Each rollout's penalty is ``coef * pass_rate * (completion tokens / max_seq_len)``
+ (``pass_rate`` = group mean reward; optionally gated to correct rollouts), and
+ this returns the group-centered negative penalty ``-(penalty_i - baseline)``.
+ Summed onto :func:`grpo_advantage` it is *identical* to subtracting the penalty
+ from each reward before centering — centering is linear, so
+ ``center(reward - penalty) = center(reward) + center(-penalty)`` — provided both
+ terms use the same baseline operator, hence ``length_weighted_baseline`` is
+ threaded here too (it picks the plain vs token-length-weighted mean, matching
+ :func:`grpo_advantage`).
+ """
+ if max_seq_len is None:
+ raise ValueError("max_seq_len is required when the linear length penalty is enabled")
+ rewards = torch.tensor([v.reward for v in group], dtype=torch.float32)
+ lengths = torch.tensor([get_model_completion_len(v.raw) for v in group], dtype=rewards.dtype)
+ penalty = config.coef * rewards.mean() * (lengths / max_seq_len)
+ if config.gate_by_correctness:
+ penalty = penalty * rewards
+ baseline = (lengths * penalty).sum() / lengths.sum() if length_weighted_baseline else penalty.mean()
+ return (baseline - penalty).tolist()
+
+
+def efficiency_shaping_advantage(
+ group: list["RolloutView"], config: TokensLengthPenaltyConfig | TurnsLengthPenaltyConfig
+) -> list[float]:
+ """Correctness-gated efficiency shaping (the ``tokens`` / ``turns`` length
+ penalties) over a per-rollout cost: weighted completion + tool-response tokens,
+ or trajectory turn count. Unlike :func:`length_penalty_advantage` this is not an
+ additive term — it amplifies correct rewards (see :func:`_efficiency_shaping`) and
+ returns the full advantage, so it replaces the GRPO baseline rather than summing
+ with it.
+ """
+ rewards = torch.tensor([v.reward for v in group], dtype=torch.float32)
+ if isinstance(config, TokensLengthPenaltyConfig):
+ w_c, w_t = config.completion_weight, config.tool_response_weight
+ costs = torch.tensor(
+ [w_c * get_model_completion_len(v.raw) + w_t * get_tool_response_len(v.raw) for v in group],
+ dtype=rewards.dtype,
+ )
+ else:
+ costs = torch.tensor([len(v.raw["trajectory"]) for v in group], dtype=rewards.dtype)
+ return _efficiency_shaping(rewards, costs).tolist()
+
+
+def max_rl_advantage_fn(group: list["RolloutView"]) -> list[float]:
+ """MaxRL advantage for a single group (arXiv:2602.02710): reward minus the
+ per-group mean, divided by that mean — equivalent to averaging score
+ functions over successful rollouts only, which makes the policy gradient
+ unbiased for the order-``group_size`` truncation of the maximum-likelihood
+ objective instead of pass@1. Assumes non-negative (canonically binary)
+ rewards; a group with mean reward <= 0 carries no signal and gets zero
+ advantages (the zero-advantage filter drops it, matching the paper's
+ no-success convention)."""
+ rewards = torch.tensor([v.reward for v in group], dtype=torch.float32)
+ mean = rewards.mean()
+ if mean <= 0:
+ return torch.zeros_like(rewards).tolist()
+ return ((rewards - mean) / mean).tolist()
+
+
+def _efficiency_shaping(
+ rewards: Float[Tensor, "group_size"],
+ costs: Float[Tensor, "group_size"],
+) -> Float[Tensor, "group_size"]:
+ """Correctness-gated efficiency shaping with bounded advantages.
+
+ Shapes rewards with a bounded efficiency bonus before standard GRPO subtraction,
+ preserving zero-mean advantages within the group. `costs` is a per-rollout cost
+ (e.g., completion length in tokens or number of turns).
+
+ Correct rollouts get reward amplified by up to 2x based on relative efficiency.
+ Incorrect rollouts are untouched. Lower-cost correct rollouts get higher advantage.
+ """
+ max_reward = rewards.max()
+ correct_mask = rewards >= max_reward
+ num_correct = correct_mask.sum()
+
+ # No shaping when max reward is 0 — no correct rollouts to differentiate
+ if max_reward <= 0:
+ return rewards - rewards.mean()
+
+ # Mean cost of correct rollouts
+ mean_correct_cost = (costs * correct_mask).sum() / num_correct.clamp(min=1)
+
+ # Bounded efficiency bonus: [0, 1], positive for below-average cost, zero for above.
+ # When mean_correct_cost is 0 (e.g. tool-only shaping with no harness metric, or
+ # all-zero turn counts), no rollouts can be differentiated — fall back to no bonus.
+ if mean_correct_cost <= 0:
+ return rewards - rewards.mean()
+
+ bonus = (1 - costs / mean_correct_cost).clamp(0, 1)
+
+ # Shape rewards: correct rollouts amplified by up to 2x, incorrect untouched
+ shaped_rewards = rewards * (1 + bonus * correct_mask)
+ return shaped_rewards - shaped_rewards.mean()
+
+
+def apply_advantage_fn(group: list["RolloutView"], advantage_fn: AdvantageFn) -> None:
+ """Run an advantage function over one finished group and write each
+ rollout's result through :meth:`RolloutView.assign_advantages` (scalar
+ broadcast or per-token list). The group-relative algorithms' ``score_group``
+ hook delegates here."""
+ for view, advs in zip(group, advantage_fn(group), strict=True):
+ view.assign_advantages(advs)
diff --git a/src/prime_rl/orchestrator/algo/base.py b/src/prime_rl/orchestrator/algo/base.py
new file mode 100644
index 0000000000..df57018719
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/base.py
@@ -0,0 +1,187 @@
+"""The per-env algorithm runtime: base class and pipeline phase functions.
+
+Each named class in this package *is* one training algorithm, one module per
+algorithm: it owns the algorithm's three scoring hooks directly —
+``score_rollout`` (per arrival), ``score_group`` (per group), ``score_batch``
+(per batch) — and declares what it needs (``action_loss_type``, a
+``model_role`` like "teacher"). Reading a module top to bottom reads the
+algorithm; writing your own is subclassing :class:`Algorithm` and overriding
+the hooks its signal needs. Shared math (group normalization, prefill
+alignment) lives as plain functions in ``advantage.py``; duplication of
+orchestration between similar algorithms (e.g. OPD and OPSD) is accepted so
+each module stays self-contained.
+
+The three hooks are one scope-and-timing ladder — each wider scope is
+unlocked by a later barrier, so the two axes coincide. All three are
+``async`` (any stage may do I/O); a hook that only does advantage math never
+awaits:
+
+- ``score_rollout(rollout)`` — one rollout, on arrival: rollout-local signals
+ (raw reward, process rewards, echo's observation weighting). No siblings.
+- ``score_group(group)`` — the cohort, on group completion, *before* filtering
+ (filters read the streams): group-relative credit (GRPO/MaxRL baselines).
+- ``score_batch(batch)`` — the batch's survivors, *after* filtering: the home
+ for reference I/O (``self.teacher_pool``), where queries are batched for
+ concurrency and — running after filtering — dropped rollouts cost nothing.
+
+How rollouts are *produced* is not the algorithm's concern: that is the env's
+:class:`~prime_rl.orchestrator.sampler.Sampler`, and sample construction
+(interleaving, with observation-token provenance recorded as ``obs_spans``)
+is pure pipeline.
+
+The pipeline (dispatcher, train sink, orchestrator) calls the module-level
+phase functions (:func:`finalize_rollout`, :func:`finalize_group`,
+:func:`finalize_batch`) and reads the class declarations; it never branches on
+algorithm config fields or model roles — liveness of a reference is the only
+runtime distinction. prime-rl hosts exactly one model — the trainable policy,
+whose pool is passed in; every frozen model reference is an external endpoint
+the algorithm *connects to* (never launches) in :meth:`Algorithm.setup`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from collections import defaultdict
+from typing import TYPE_CHECKING, ClassVar
+
+from prime_rl.configs.algorithm import ActionLossType, AdvantageConfig, FrozenModelConfig, ModelReference
+from prime_rl.orchestrator.algo.routing import stamp_advantages, stamp_loss_routing
+from prime_rl.orchestrator.types import RolloutView
+from prime_rl.utils.logger import get_logger
+
+if TYPE_CHECKING:
+ from renderers.base import Renderer
+
+ from prime_rl.orchestrator.envs import TrainEnvs
+ from prime_rl.orchestrator.types import TrainRollout
+ from prime_rl.utils.client import InferencePool
+
+
+async def connect_frozen_pool(config: FrozenModelConfig) -> InferencePool:
+ """Connect a client pool to an inline frozen model and wait for it to be
+ ready. The endpoint is externally hosted — prime-rl connects and waits,
+ never launches."""
+ from prime_rl.utils.client import setup_inference_pool
+
+ get_logger().info(f"Initializing frozen model pool (model={config.name}, base_url={', '.join(config.base_url)})")
+ pool = await setup_inference_pool(config, model_name=config.name)
+ await pool.wait_for_ready(config.name)
+ return pool
+
+
+class Algorithm:
+ """Base class for one env's training algorithm — the runtime of the
+ bundle's ``advantage`` component (its sibling :class:`Sampler` interprets
+ ``sampling``).
+
+ Everything on this class is yours to override; the pipeline drives the
+ compilation through the module-level phase functions below
+ (:func:`finalize_rollout` / :func:`finalize_group` / :func:`finalize_batch`)
+ and never calls anything else. The surface is:
+
+ - declarations — which loss component the action tokens feed
+ (``action_loss_type``) and what the algorithm calls its reference
+ model, if it has one (``model_role``, e.g. "teacher");
+ - lifecycle — :meth:`setup` connects client pools to the frozen models
+ the algorithm declares, resolving each reference via :meth:`connect`;
+ - the three scoring hooks, each ``async`` and given a :class:`RolloutView`
+ (a writable handle exposing only what is valid at its stage). They are
+ async so any stage may do I/O — e.g. a process-reward model at arrival,
+ or a judge at group time whose signal a pre-batch filter then reads; a
+ hook that only does advantage math simply never awaits.
+
+ - :meth:`score_rollout` — one rollout, on arrival: rollout-local credit
+ or observation ce weights. Default: nothing.
+ - :meth:`score_group` — the cohort, *before* filtering (filters read the
+ streams): group-relative credit. Default: nothing — rollouts keep
+ ``advantages=None``, so advantage-based filters skip them.
+ - :meth:`score_batch` — the batch's survivors, *after* filtering:
+ query the algorithm's reference pool (e.g. ``self.teacher_pool``) and
+ attach per-token results, or modulate advantages. Default: nothing.
+
+ ``score_batch`` is the home for reference I/O: it runs after filtering, so
+ only survivors cost reference compute. I/O in ``score_rollout`` /
+ ``score_group`` runs *before* the pre-batch filters — do it when a filter
+ must read the result, accepting that it pays compute on rollouts that may
+ then be filtered out.
+
+ Constructed with the advantage component it interprets plus the two
+ host-owned resources: the policy pool and the policy's renderer (the
+ canonical messages → token ids path; ``None`` under MITO)."""
+
+ action_loss_type: ClassVar[ActionLossType] = "rl"
+ model_role: ClassVar[str | None] = None
+
+ def __init__(self, advantage: AdvantageConfig, policy_pool: InferencePool, renderer: Renderer | None):
+ self.advantage = advantage
+ self.policy_pool = policy_pool
+ self.renderer = renderer
+ self.connected_pools: list[InferencePool] = [] # client pools connected in setup(); closed at shutdown
+ # Training sequence length, injected by build_algorithm — the denominator
+ # of the GRPO linear length penalty. None when the host doesn't set it.
+ self.max_seq_len: int | None = None
+
+ async def setup(self) -> None:
+ """Connect client pools to the algorithm's frozen models — override
+ and resolve each reference via :meth:`connect`. The base has nothing
+ to connect."""
+
+ async def connect(self, reference: ModelReference) -> InferencePool:
+ """Resolve a model reference to a client pool: the live policy's own
+ pool, or a freshly connected pool to a frozen endpoint. Only the
+ latter is tracked in ``connected_pools`` — the host closes what the
+ algorithm opened, and nothing else, at shutdown."""
+ if reference == "policy":
+ return self.policy_pool
+ pool = await connect_frozen_pool(reference)
+ self.connected_pools.append(pool)
+ return pool
+
+ async def score_rollout(self, rollout: RolloutView) -> None:
+ """Arrival phase, one rollout, before its group is complete: write
+ rollout-local credit (``rollout.assign_advantages``) or observation ce
+ weights (echo). No siblings, no group stats."""
+
+ async def score_group(self, group: list[RolloutView]) -> None:
+ """Group phase, the finalized cohort, before filtering: write
+ group-relative credit."""
+
+ async def score_batch(self, batch: list[RolloutView]) -> None:
+ """Ship phase, survivors only, after filtering, async: query the
+ algorithm's reference models and attach per-token results, or modulate
+ advantages."""
+
+
+async def finalize_rollout(algorithm: Algorithm, rollout: TrainRollout) -> None:
+ """Arrival phase: rollout-local scoring as each rollout is tokenized."""
+ if rollout.samples:
+ await algorithm.score_rollout(RolloutView(rollout))
+
+
+async def finalize_group(algorithm: Algorithm, rollouts: list[TrainRollout]) -> None:
+ """Group phase: group-relative scoring, then stamp each sample's wire
+ fields (the advantage stream + loss routing). After this the records are
+ frozen — groups die at stamping."""
+ await algorithm.score_group([RolloutView(rollout) for rollout in rollouts])
+ for rollout in rollouts:
+ stamp_advantages(rollout)
+ for sample in rollout.samples:
+ sample.reward = rollout.reward
+ sample.env_name = rollout.env_name
+ stamp_loss_routing(sample, algorithm.action_loss_type)
+
+
+async def finalize_batch(train_envs: TrainEnvs, rollouts: list[TrainRollout]) -> None:
+ """Ship phase: run each env's ``score_batch`` over its unfiltered rollouts
+ (survivors), concurrently across envs. Per-env concurrency is bounded by
+ the algorithm's own config; envs without references return immediately."""
+ by_env: dict[str, list[TrainRollout]] = defaultdict(list)
+ for rollout in rollouts:
+ if not rollout.is_filtered:
+ by_env[rollout.env_name].append(rollout)
+ await asyncio.gather(
+ *(
+ train_envs.get(env_name).algorithm.score_batch([RolloutView(r) for r in env_rollouts])
+ for env_name, env_rollouts in by_env.items()
+ )
+ )
diff --git a/src/prime_rl/orchestrator/algo/custom.py b/src/prime_rl/orchestrator/algo/custom.py
new file mode 100644
index 0000000000..6036e0e208
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/custom.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prime_rl.configs.algorithm import AdvantageConfig, CustomAdvantageConfig
+from prime_rl.orchestrator.algo.advantage import apply_advantage_fn
+from prime_rl.orchestrator.algo.base import Algorithm
+from prime_rl.utils.utils import import_object
+
+if TYPE_CHECKING:
+ from renderers.base import Renderer
+
+ from prime_rl.orchestrator.types import RolloutView
+ from prime_rl.utils.client import InferencePool
+
+
+class CustomAlgorithm(Algorithm):
+ """User-supplied advantage function — the ``score_group`` hook body without
+ the class: receives the group's ``RolloutView``\\ s, returns one value per
+ rollout (a scalar broadcast over its completion tokens, or a per-token
+ list)."""
+
+ def __init__(self, advantage: AdvantageConfig, policy_pool: InferencePool, renderer: Renderer | None):
+ super().__init__(advantage, policy_pool, renderer)
+ assert isinstance(advantage, CustomAdvantageConfig)
+ custom_fn = import_object(advantage.import_path)
+ kwargs = advantage.kwargs
+
+ def advantage_fn(group: list[RolloutView]) -> list[float | list[float]]:
+ return custom_fn(group, **kwargs)
+
+ self.advantage_fn = advantage_fn
+
+ async def score_group(self, group: list[RolloutView]) -> None:
+ apply_advantage_fn(group, self.advantage_fn)
diff --git a/src/prime_rl/orchestrator/algo/echo.py b/src/prime_rl/orchestrator/algo/echo.py
new file mode 100644
index 0000000000..24c954ebba
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/echo.py
@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+from functools import partial
+from typing import TYPE_CHECKING, Any, Callable
+
+from prime_rl.configs.algorithm import AdvantageConfig, EchoAdvantageConfig
+from prime_rl.orchestrator.algo.grpo import GRPOAlgorithm
+from prime_rl.utils.utils import import_object
+
+if TYPE_CHECKING:
+ import verifiers as vf
+ from renderers.base import Renderer
+
+ from prime_rl.orchestrator.types import RolloutView
+ from prime_rl.utils.client import InferencePool
+
+
+def _prompt_role_weights(tokens: dict[str, Any], role_weights: dict[str, float]) -> list[float]:
+ """Per-token echo weights over one step's prompt tokens.
+
+ Each token gets its message role's weight (0.0 for unselected roles), via
+ the renderer's per-token attribution — message content bodies when the
+ renderer provides ``is_content``, whole messages otherwise."""
+ attribution = tokens.get("prompt_attribution")
+ if attribution is None:
+ raise ValueError(
+ "echo selects env-provided tokens by message role, which needs the renderer's "
+ "per-token attribution — MITO rollouts don't carry it; set orchestrator.renderer."
+ )
+
+ # Serialized steps carry the attribution as a dict of RenderedTokens
+ # fields; in-process steps may carry the dataclass itself.
+ def field(key: str) -> Any:
+ return attribution.get(key) if isinstance(attribution, dict) else getattr(attribution, key, None)
+
+ indices = field("message_indices")
+ roles = field("message_roles")
+ is_content = field("is_content") or []
+ weights = []
+ for k in range(len(tokens["prompt_ids"])):
+ idx = indices[k]
+ selected = idx >= 0 and roles[idx] in role_weights
+ if selected and is_content:
+ selected = bool(is_content[k])
+ weights.append(role_weights[roles[idx]] if selected else 0.0)
+ return weights
+
+
+class EchoAlgorithm(GRPOAlgorithm):
+ """GRPO on action tokens, plus weighted CE on env-provided tokens of
+ later turns (tool output, user feedback), selected by message role —
+ tool-response bodies at the vetted default. Selected tokens feed the
+ ``ce`` loss component at their role's ``alpha`` and stay outside the rl
+ mask and its denominator. An optional user filter narrows the selection
+ per rollout (e.g. dropping tool-output warnings)."""
+
+ def __init__(self, advantage: AdvantageConfig, policy_pool: InferencePool, renderer: Renderer | None):
+ super().__init__(advantage, policy_pool, renderer)
+ assert isinstance(advantage, EchoAdvantageConfig)
+ self.role_weights = {
+ role: role_config.alpha
+ for role in ("system", "user", "assistant", "tool")
+ if (role_config := getattr(advantage.roles, role)) is not None
+ }
+ self.filter_fn: Callable[..., list[list[bool]]] | None = None
+ if advantage.filter is not None:
+ self.filter_fn = partial(import_object(advantage.filter.import_path), **advantage.filter.kwargs)
+
+ async def score_rollout(self, rollout: RolloutView) -> None:
+ # Observation weighting is rollout-local; the group-relative GRPO
+ # baseline is inherited unchanged as ``score_group``.
+ self._weight_observations(rollout)
+
+ def _weight_observations(self, rollout: RolloutView) -> None:
+ """Write each sample's ``ce_weights`` stream for the env-provided
+ observation spans interleaving recorded (``obs_spans``): each token
+ gets its message role's weight, narrowed by the optional user filter.
+ The selected tokens stay outside ``completion_mask``, so ce is the
+ only component that trains them. Step attribution is looked up
+ lazily — only steps whose prompt tokens actually landed as
+ observations are computed; samples where nothing is selected ship no
+ ce stream at all."""
+ trajectory = rollout.raw["trajectory"]
+ filter_masks = self._filter_masks(rollout.raw) if self.filter_fn is not None else None
+ step_weights: dict[int, list[float]] = {}
+ for sample in rollout.samples:
+ if not sample.obs_spans:
+ continue
+ weights = [0.0] * len(sample.completion_ids)
+ for start, step_idx, step_start, length in sample.obs_spans:
+ if step_idx not in step_weights:
+ prompt_weights = _prompt_role_weights(trajectory[step_idx]["tokens"], self.role_weights)
+ if filter_masks is not None:
+ # Masks span the step's prompt+completion; obs spans
+ # only ever come from the prompt part.
+ prompt_weights = [w if keep else 0.0 for w, keep in zip(prompt_weights, filter_masks[step_idx])]
+ step_weights[step_idx] = prompt_weights
+ weights[start : start + length] = step_weights[step_idx][step_start : step_start + length]
+ if any(weights):
+ sample.ce_weights = [0.0] * len(sample.prompt_ids) + weights
+
+ def _filter_masks(self, output: vf.RolloutOutput) -> list[list[bool]]:
+ """Invoke the user echo filter and validate its shape: one keep-mask
+ per trajectory step, each spanning that step's ``prompt_ids`` +
+ ``completion_ids``."""
+ assert self.filter_fn is not None
+ trajectory = output["trajectory"]
+ masks = self.filter_fn(output)
+ if not isinstance(masks, list) or len(masks) != len(trajectory):
+ got = len(masks) if isinstance(masks, list) else type(masks).__name__
+ raise ValueError(
+ f"echo filter must return one keep-mask per trajectory step: got {got}, expected {len(trajectory)}"
+ )
+ for step_idx, (step, mask) in enumerate(zip(trajectory, masks)):
+ tokens = step["tokens"]
+ expected = len(tokens["prompt_ids"]) + len(tokens["completion_ids"])
+ if not isinstance(mask, list) or len(mask) != expected:
+ got = len(mask) if isinstance(mask, list) else type(mask).__name__
+ raise ValueError(
+ f"echo filter mask for step {step_idx} must span the step's prompt+completion "
+ f"tokens: got {got}, expected {expected}"
+ )
+ return masks
diff --git a/src/prime_rl/orchestrator/algo/grpo.py b/src/prime_rl/orchestrator/algo/grpo.py
new file mode 100644
index 0000000000..7fdb524d39
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/grpo.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prime_rl.configs.algorithm import (
+ AdvantageConfig,
+ GRPOAdvantageConfig,
+ LinearLengthPenaltyConfig,
+ TokensLengthPenaltyConfig,
+ TurnsLengthPenaltyConfig,
+)
+from prime_rl.orchestrator.algo.advantage import (
+ efficiency_shaping_advantage,
+ grpo_advantage,
+ length_penalty_advantage,
+)
+from prime_rl.orchestrator.algo.base import Algorithm
+
+if TYPE_CHECKING:
+ from renderers.base import Renderer
+
+ from prime_rl.orchestrator.types import RolloutView
+ from prime_rl.utils.client import InferencePool
+
+
+class GRPOAlgorithm(Algorithm):
+ """Group Relative Policy Optimization: sample a group of rollouts from the
+ policy per example; credit = reward minus the group mean (optionally
+ length-shaped); action tokens feed the ``rl`` loss."""
+
+ def __init__(self, advantage: AdvantageConfig, policy_pool: InferencePool, renderer: Renderer | None):
+ super().__init__(advantage, policy_pool, renderer)
+ assert isinstance(advantage, GRPOAdvantageConfig)
+ self.length_penalty = advantage.length_penalty
+ self.length_weighted_baseline = advantage.length_weighted_baseline
+
+ async def score_group(self, group: list[RolloutView]) -> None:
+ length_penalty = self.length_penalty
+ # tokens/turns are non-additive reward shaping — they replace the baseline.
+ if isinstance(length_penalty, (TokensLengthPenaltyConfig, TurnsLengthPenaltyConfig)):
+ advantages = efficiency_shaping_advantage(group, length_penalty)
+ else:
+ # The linear length penalty is a separate advantage that sums onto GRPO's.
+ advantages = grpo_advantage(group, self.length_weighted_baseline)
+ if isinstance(length_penalty, LinearLengthPenaltyConfig):
+ penalty = length_penalty_advantage(
+ group, length_penalty, self.max_seq_len, self.length_weighted_baseline
+ )
+ advantages = [a + p for a, p in zip(advantages, penalty, strict=True)]
+ for view, advantage in zip(group, advantages, strict=True):
+ view.assign_advantages(advantage)
diff --git a/src/prime_rl/orchestrator/algo/max_rl.py b/src/prime_rl/orchestrator/algo/max_rl.py
new file mode 100644
index 0000000000..039f44be50
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/max_rl.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prime_rl.orchestrator.algo.advantage import apply_advantage_fn, max_rl_advantage_fn
+from prime_rl.orchestrator.algo.base import Algorithm
+
+if TYPE_CHECKING:
+ from prime_rl.orchestrator.types import RolloutView
+
+
+class MaxRLAlgorithm(Algorithm):
+ """Maximum-likelihood RL (arXiv:2602.02710): the GRPO pipeline with
+ mean-normalized advantages — ``(reward − group mean) / group mean``
+ instead of plain centering. The mean normalization upweights low-pass-rate
+ examples like the maximum-likelihood gradient does, and ``group_size``
+ doubles as the truncation order of the likelihood expansion the gradient
+ is unbiased for (REINFORCE at 1 → exact maximum likelihood as it grows)."""
+
+ async def score_group(self, group: list[RolloutView]) -> None:
+ apply_advantage_fn(group, max_rl_advantage_fn)
diff --git a/src/prime_rl/orchestrator/algo/opd.py b/src/prime_rl/orchestrator/algo/opd.py
new file mode 100644
index 0000000000..b41e9645e4
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/opd.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import asyncio
+from itertools import cycle
+from typing import TYPE_CHECKING
+
+from prime_rl.configs.algorithm import AdvantageConfig, OPDAdvantageConfig
+from prime_rl.orchestrator.algo.base import Algorithm
+from prime_rl.orchestrator.utils import compute_prefill_logprobs
+
+if TYPE_CHECKING:
+ from renderers.base import Renderer
+
+ from prime_rl.orchestrator.types import RolloutView
+ from prime_rl.transport import TrainingSample
+ from prime_rl.utils.client import InferencePool
+
+
+class OPDAlgorithm(Algorithm):
+ """On-policy distillation. Needs a teacher: the frozen reference model the
+ per-token reverse KL is computed against.
+
+ The policy samples its own rollouts; at ship time each sample's full
+ context is prefill-scored under the teacher (``ref_logprobs`` on the
+ wire), and the trainer evaluates the KL against the live policy. No
+ credit is assigned — rollouts keep ``advantages=None`` (advantage-based
+ filters never fire) and samples ship no advantage stream; ``group_size``
+ only fans out sampling."""
+
+ action_loss_type = "ref_kl"
+ model_role = "teacher"
+
+ def __init__(self, advantage: AdvantageConfig, policy_pool: InferencePool, renderer: Renderer | None):
+ super().__init__(advantage, policy_pool, renderer)
+ assert isinstance(advantage, OPDAdvantageConfig)
+ self.max_concurrent = advantage.max_concurrent
+ self.teacher = advantage.model
+ self.teacher_pool: InferencePool | None = None # connected in setup()
+
+ async def setup(self) -> None:
+ self.teacher_pool = await self.connect(self.teacher)
+
+ async def score_batch(self, batch: list[RolloutView]) -> None:
+ pool = self.teacher_pool
+ assert pool is not None, "teacher pool not connected — Algorithm.setup() must run first"
+ semaphore = asyncio.Semaphore(self.max_concurrent)
+ samples = [sample for view in batch for sample in view.samples]
+
+ async def score_sample(client, sample: TrainingSample) -> None:
+ async with semaphore:
+ token_ids = list(sample.prompt_ids) + list(sample.completion_ids)
+ sample.ref_logprobs = await compute_prefill_logprobs(client, pool.model_name, token_ids)
+
+ await asyncio.gather(
+ *[score_sample(client, sample) for client, sample in zip(cycle(pool.train_clients), samples)]
+ )
diff --git a/src/prime_rl/orchestrator/algo/opsd.py b/src/prime_rl/orchestrator/algo/opsd.py
new file mode 100644
index 0000000000..e8cddb1ff8
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/opsd.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+import asyncio
+from itertools import cycle
+from typing import TYPE_CHECKING
+
+from prime_rl.configs.algorithm import AdvantageConfig, OPSDAdvantageConfig
+from prime_rl.orchestrator.algo.base import Algorithm
+from prime_rl.orchestrator.utils import compute_prefill_logprobs
+
+if TYPE_CHECKING:
+ from renderers.base import Renderer
+
+ from prime_rl.orchestrator.types import RolloutView
+ from prime_rl.utils.client import InferencePool
+
+
+class OPSDAlgorithm(Algorithm):
+ """On-policy self-distillation (SDFT). The teacher defaults to the policy
+ itself, conditioned on an expert demonstration — no extra deployment.
+
+ The scoring prefix is rebuilt from the rollout's first-turn prompt
+ messages with the demonstration woven into the last user message; the
+ returned completion logprobs are aligned back onto the sample (the
+ sample's prompt positions are 0.0 and stay outside the loss mask). No
+ scalar advantage is assigned."""
+
+ action_loss_type = "ref_kl"
+ model_role = "teacher"
+
+ def __init__(self, advantage: AdvantageConfig, policy_pool: InferencePool, renderer: Renderer | None):
+ super().__init__(advantage, policy_pool, renderer)
+ assert isinstance(advantage, OPSDAdvantageConfig)
+ assert renderer is not None, "opsd requires the renderer (validated at config time)"
+ self.demo_key = advantage.demo_key
+ self.template = advantage.template
+ self.max_concurrent = advantage.max_concurrent
+ self.teacher = advantage.model
+ self.teacher_pool: InferencePool | None = None # connected in setup()
+
+ async def setup(self) -> None:
+ self.teacher_pool = await self.connect(self.teacher)
+
+ def _ref_prefix_ids(self, rollout: RolloutView) -> list[int]:
+ trajectory = rollout.raw.get("trajectory") or []
+ if len(trajectory) != 1:
+ raise ValueError(
+ f"opsd supports single-step trajectories only; "
+ f"env '{rollout.env_name}' produced {len(trajectory)} steps."
+ )
+ info = rollout.raw.get("info") or {}
+ demonstration = info.get(self.demo_key) if isinstance(info, dict) else None
+ if demonstration is None:
+ demonstration = rollout.raw.get(self.demo_key)
+ if demonstration is None:
+ raise ValueError(
+ f"opsd requires '{self.demo_key}' in the example's info dict or as a "
+ f"top-level rollout field (env '{rollout.env_name}', example {rollout.example_id})."
+ )
+
+ messages = [dict(m) for m in trajectory[0]["prompt"]]
+ user_indices = [i for i, m in enumerate(messages) if m.get("role") == "user"]
+ if not user_indices:
+ raise ValueError(f"opsd found no user message to condition (env '{rollout.env_name}').")
+ last_user = messages[user_indices[-1]]
+ question = last_user.get("content")
+ if not isinstance(question, str):
+ raise ValueError("opsd supports text-only prompts (user content must be a string).")
+ last_user["content"] = self.template.format(question=question, demonstration=demonstration)
+
+ # Render through the policy's renderer — the same messages → token ids
+ # path the policy's own prompts take, so the scoring prefix matches
+ # the prompt distribution the teacher conditions on.
+ assert self.renderer is not None
+ return self.renderer.render_ids(messages, add_generation_prompt=True)
+
+ async def score_batch(self, batch: list[RolloutView]) -> None:
+ pool = self.teacher_pool
+ assert pool is not None, "teacher pool not connected — Algorithm.setup() must run first"
+ semaphore = asyncio.Semaphore(self.max_concurrent)
+
+ async def score_one(client, rollout: RolloutView) -> None:
+ prefix_ids = self._ref_prefix_ids(rollout)
+ assert len(rollout.samples) == 1 # single-step trajectory → one sample
+ sample = rollout.samples[0]
+ async with semaphore:
+ full_logprobs = await compute_prefill_logprobs(
+ client, pool.model_name, prefix_ids + list(sample.completion_ids)
+ )
+ completion_logprobs = full_logprobs[-len(sample.completion_ids) :]
+ sample.ref_logprobs = [0.0] * len(sample.prompt_ids) + completion_logprobs
+
+ await asyncio.gather(*[score_one(client, rollout) for client, rollout in zip(cycle(pool.train_clients), batch)])
diff --git a/src/prime_rl/orchestrator/algo/reward.py b/src/prime_rl/orchestrator/algo/reward.py
new file mode 100644
index 0000000000..64bc040943
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/reward.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prime_rl.orchestrator.algo.base import Algorithm
+
+if TYPE_CHECKING:
+ from prime_rl.orchestrator.types import RolloutView
+
+
+class RewardAlgorithm(Algorithm):
+ """REINFORCE-style: credit = raw reward, no group baseline. Purely
+ rollout-local — no siblings needed — so it scores on arrival; action
+ tokens feed the ``rl`` loss."""
+
+ async def score_rollout(self, rollout: RolloutView) -> None:
+ rollout.assign_advantages(rollout.reward)
diff --git a/src/prime_rl/orchestrator/algo/routing.py b/src/prime_rl/orchestrator/algo/routing.py
new file mode 100644
index 0000000000..643b5bb6bb
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/routing.py
@@ -0,0 +1,76 @@
+"""Wire-field stamping for the per-token streams.
+
+The training loss is a sum of three components — ``rl`` (importance-weighted
+PG + KL), ``ce`` (masked NLL), and ``ref_kl`` (reverse KL to a reference model
+as the PG signal) — each normalized by its own global token count in the
+trainer. The advantage strategy decides which component the action tokens feed
+and the per-token advantages the rl component consumes; these helpers write
+the component weight streams and the advantage stream onto the
+``TrainingSample`` wire fields at group finalization.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prime_rl.configs.algorithm import ActionLossType
+from prime_rl.transport import TrainingSample
+
+if TYPE_CHECKING:
+ from prime_rl.orchestrator.types import TrainRollout
+
+
+def stamp_loss_routing(sample: TrainingSample, action_loss_type: ActionLossType) -> None:
+ """Stamp the algorithm's loss routing onto one sample's component weight
+ streams: action tokens (the trainable completion tokens, per the loss
+ mask) feed the algorithm's declared component.
+
+ ``rl`` is the default and ships nothing (absent streams mean rl weight
+ 1.0 on the loss mask — the hot path); ``ce``/``ref_kl`` weight the action
+ tokens into that component's stream and zero the rl stream. Streams an
+ algorithm wrote directly (echo's observation ce weights) are merged, not
+ clobbered — env-provided tokens stay out of ``completion_mask``, so the
+ component an algorithm weights them into is the only one that trains
+ them.
+ """
+ sample.obs_spans = None # orchestrator-internal provenance, never ships
+ if action_loss_type == "rl":
+ return
+
+ prompt_len = len(sample.prompt_ids)
+ seq_len = prompt_len + len(sample.completion_ids)
+ sample.rl_weights = [0.0] * seq_len
+ action_weights = (
+ sample.ce_weights if action_loss_type == "ce" and sample.ce_weights is not None else [0.0] * seq_len
+ )
+ for i, trains in enumerate(sample.completion_mask):
+ if trains:
+ action_weights[prompt_len + i] = 1.0
+ if action_loss_type == "ce":
+ sample.ce_weights = action_weights
+ else:
+ assert action_loss_type == "ref_kl"
+ sample.ref_kl_weights = action_weights
+
+
+def stamp_advantages(rollout: TrainRollout) -> None:
+ """Stamp the rollout's per-token advantage stream onto its samples' wire
+ fields, padded with 0.0 over prompt positions (never trained). The stream
+ is aligned to the samples' completion tokens (concatenated in step order)
+ and sliced across them. Rollouts with no credit assigned
+ (``advantages=None``, e.g. opd/opsd) ship no advantage stream.
+ """
+ advantages = rollout.advantages
+ if advantages is None:
+ return
+ total = sum(len(sample.completion_ids) for sample in rollout.samples)
+ if len(advantages) != total:
+ raise ValueError(
+ f"advantage stream must align with the rollout's completion tokens: "
+ f"got {len(advantages)}, expected {total} (env '{rollout.env_name}')."
+ )
+ offset = 0
+ for sample in rollout.samples:
+ num_completion = len(sample.completion_ids)
+ sample.advantages = [0.0] * len(sample.prompt_ids) + advantages[offset : offset + num_completion]
+ offset += num_completion
diff --git a/src/prime_rl/orchestrator/algo/sft.py b/src/prime_rl/orchestrator/algo/sft.py
new file mode 100644
index 0000000000..b1fb49e9d2
--- /dev/null
+++ b/src/prime_rl/orchestrator/algo/sft.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prime_rl.orchestrator.algo.advantage import apply_advantage_fn, grpo_advantage
+from prime_rl.orchestrator.algo.base import Algorithm
+
+if TYPE_CHECKING:
+ from prime_rl.orchestrator.types import RolloutView
+
+
+class SFTDistillAlgorithm(Algorithm):
+ """Hard distillation. Needs a teacher: the frozen model that generates the
+ rollouts (``sampling.source``); the policy trains with CE on its tokens.
+
+ The ``ce`` loss ignores credit, but group-relative advantages are still
+ assigned so reward-based filtering keeps working."""
+
+ action_loss_type = "ce"
+
+ async def score_group(self, group: list[RolloutView]) -> None:
+ apply_advantage_fn(group, grpo_advantage)
diff --git a/src/prime_rl/orchestrator/dispatcher.py b/src/prime_rl/orchestrator/dispatcher.py
index 7ebb0149d2..36a8a0fd10 100644
--- a/src/prime_rl/orchestrator/dispatcher.py
+++ b/src/prime_rl/orchestrator/dispatcher.py
@@ -14,7 +14,9 @@
- ``on_new_version`` (called by the watcher) bumps ``off_policy_steps`` on
in-flight train rollouts and drops groups past ``max_off_policy_steps``.
Eval rollouts are measurements for the policy version they started with,
- so they are allowed to finish even if training advances.
+ so they are allowed to finish even if training advances. Train rollouts
+ sampled from a frozen model never age — their sampler doesn't change
+ with policy updates.
Cancellations surface as synthetic ``Cancelled`` markers so the sink's
count-to-``group_size`` finalization still fires.
"""
@@ -126,26 +128,20 @@ def __init__(
eval_envs: EvalEnvs | None,
train_source: TrainSource,
eval_source: EvalSource | None,
- inference: InferencePool,
- eval_inference: InferencePool,
+ policy_pool: InferencePool,
policy: Policy,
max_inflight_rollouts: int,
tasks_per_minute: float | None,
max_off_policy_steps: int,
- training_mode: Literal["rl", "opd", "sft"],
- use_cache_salt: bool = True,
) -> None:
self.policy = policy
self.train_envs = train_envs
self.eval_envs = eval_envs
- # Train rollouts go to ``inference`` (the teacher in SFT mode);
- # eval always evaluates the student, so it uses ``eval_inference``.
- self.inference = inference
- self.eval_inference = eval_inference
+ # Train rollouts go to the env sampler's pool; eval always
+ # evaluates the policy.
+ self.policy_pool = policy_pool
self.train_source = train_source
self.eval_source = eval_source
- self.training_mode = training_mode
- self.use_cache_salt = use_cache_salt
self.max_off_policy_steps = max_off_policy_steps
self.max_inflight = max_inflight_rollouts
@@ -175,14 +171,13 @@ def __init__(
self.stopped = asyncio.Event()
self.task: asyncio.Task | None = None
- @property
- def train_model_name(self) -> str:
- """Model name for *train* rollouts. In SFT mode train data comes from
- the teacher pool, so use its model name; otherwise the live student
- policy. (Eval always uses ``policy.model_name`` — the student.)"""
- if self.training_mode == "sft":
- return self.inference.model_name
- return self.policy.model_name
+ def _train_pool_for(self, env_name: str) -> tuple[InferencePool, str, bool]:
+ """``(pool, model_name, is_live)`` for *train* rollouts of this env —
+ the env sampler's pool. (Eval always uses the policy.)"""
+ sampler = self.train_envs.get(env_name).sampler
+ if sampler.samples_from_live_policy:
+ return sampler.pool, self.policy.model_name, True
+ return sampler.pool, sampler.pool.model_name, False
@property
def inflight_train_count(self) -> int:
@@ -273,6 +268,10 @@ async def on_new_version(self, step: int) -> None:
for meta in self.inflight.values():
if meta.kind != "train":
continue
+ # Frozen-sourced rollouts never go stale — their sampler doesn't
+ # change with policy updates.
+ if not self.train_envs.get(meta.env_name).sampler.samples_from_live_policy:
+ continue
meta.off_policy_steps += 1
if meta.off_policy_steps > self.max_off_policy_steps:
stale_groups.add(meta.group_id)
@@ -387,14 +386,15 @@ async def schedule_group_rollout(self, group_id: uuid.UUID, group: GroupState) -
ready, no permits). Returns True after issuing one task — the caller
loops to keep scheduling.
"""
- # Train rollouts use the rollout pool (teacher in SFT) via the
- # renderer/token train client. Eval always evaluates the student and
+ # Train rollouts use the env sampler's pool via the
+ # renderer/token train client. Eval always evaluates the policy and
# goes through the eval client (chat-completions) — the same path the
# legacy orchestrator used, so eval scores stay comparable.
if group.kind == "eval":
- pool, model_name = self.eval_inference, self.policy.model_name
+ pool, model_name = self.policy_pool, self.policy.model_name
+ live_sourced = True
else:
- pool, model_name = self.inference, self.train_model_name
+ pool, model_name, live_sourced = self._train_pool_for(group.env_name)
# Pin a single client per group to keep prefix-cache hits
if group.pinned_client is None:
@@ -415,7 +415,10 @@ async def schedule_group_rollout(self, group_id: uuid.UUID, group: GroupState) -
if env_collection is None:
return False
env = env_collection.get(group.env_name)
- if group.kind == "eval" or self.use_cache_salt:
+ # Frozen-sourced train rollouts hit a frozen pool; salting per policy
+ # version would invalidate its prefix cache every weight update for
+ # no reason.
+ if live_sourced:
cache_salt = str(group.policy_version_at_start)
else:
cache_salt = None
diff --git a/src/prime_rl/orchestrator/envs.py b/src/prime_rl/orchestrator/envs.py
index 34e12aa63f..2891e19cff 100644
--- a/src/prime_rl/orchestrator/envs.py
+++ b/src/prime_rl/orchestrator/envs.py
@@ -12,7 +12,8 @@
from verifiers.utils.serve_utils import get_free_port
from prime_rl.configs.orchestrator import EnvConfig, EvalEnvConfig, TrainEnvConfig
-from prime_rl.orchestrator.advantage import AdvantageFn, setup_advantage_fn
+from prime_rl.orchestrator.algo import Algorithm, build_algorithm
+from prime_rl.orchestrator.sampler import Sampler
from prime_rl.utils.logger import get_logger
REQUIRED_STATE_COLUMNS = ["trajectory"]
@@ -162,14 +163,11 @@ def shutdown(self) -> None:
class TrainEnv(Env):
config: TrainEnvConfig
- def __init__(self, config: TrainEnvConfig):
+ def __init__(self, config: TrainEnvConfig, sampler: Sampler, algorithm: Algorithm):
super().__init__(config)
- self.sampling_args = config.sampling.to_sampling_args()
- # Built once — custom advantage funcs do an ``import_object`` we don't
- # want to pay per group. ``None`` = reward-only path.
- self.advantage_fn: AdvantageFn | None = (
- setup_advantage_fn(config.advantage) if config.advantage is not None else None
- )
+ self.sampler = sampler
+ self.algorithm = algorithm
+ self.sampling_args = sampler.sampling_args(config.sampling.to_sampling_args())
def get_dataset(self, seed: int | None = None):
return self.env.get_dataset(seed=seed)
@@ -244,12 +242,19 @@ def shutdown(self) -> None:
class TrainEnvs(Envs[TrainEnv]):
- """Collection of training environments."""
+ """Collection of training environments, each paired with its rollout
+ :class:`Sampler` and runtime :class:`Algorithm`, built from the env's
+ resolved algorithm config."""
- def __init__(self, configs: Sequence[TrainEnvConfig]):
+ def __init__(self, configs: Sequence[TrainEnvConfig], *, policy_pool, renderer, max_seq_len: int):
self._envs: dict[str, TrainEnv] = {}
for config in configs:
- env = TrainEnv(config)
+ assert config.algo is not None, "TrainEnvConfig.algo must be resolved before env construction"
+ env = TrainEnv(
+ config,
+ Sampler(config.algo.sampling, policy_pool),
+ build_algorithm(config.algo, policy_pool, renderer, max_seq_len=max_seq_len),
+ )
self._envs[env.name] = env
diff --git a/src/prime_rl/orchestrator/filters.py b/src/prime_rl/orchestrator/filters.py
index f8deda1230..782438baa5 100644
--- a/src/prime_rl/orchestrator/filters.py
+++ b/src/prime_rl/orchestrator/filters.py
@@ -99,14 +99,14 @@ def check(self, rollout: "TrainRollout") -> FilterResult:
@dataclass
class ZeroAdvantageFilter:
- """Flags rollouts whose computed advantage is zero (e.g. all rollouts in a
- GRPO group earned the same reward, so the centered advantage collapses)."""
+ """Flags rollouts whose advantage stream is all zero (e.g. all rollouts in
+ a GRPO group earned the same reward, so the centered advantage collapses)."""
name: str
enforce: bool = True
def check(self, rollout: "TrainRollout") -> FilterResult:
- if rollout.advantage is not None and rollout.advantage == 0.0:
+ if rollout.advantages is not None and all(a == 0.0 for a in rollout.advantages):
return FilterResult(detected=True)
return FilterResult(detected=False)
diff --git a/src/prime_rl/orchestrator/metrics.py b/src/prime_rl/orchestrator/metrics.py
index d32dbc02de..5dca63be56 100644
--- a/src/prime_rl/orchestrator/metrics.py
+++ b/src/prime_rl/orchestrator/metrics.py
@@ -32,7 +32,7 @@ def build(
progress: Progress,
step_time: float,
save_ckpt_time: float,
- teacher_logprobs_time: float,
+ scoring_time: float,
pre_filter_seen: int,
pre_filter_dropped: int,
pre_filter_dropped_by_name: dict[str, int],
@@ -132,7 +132,7 @@ def compute_solve_rates(df):
"effective_batch_size/all": effective_batch_size,
**{f"batch/{env}": r for env, r in results_df.env_name.value_counts(normalize=True).items()},
"time/step": step_time,
- "time/teacher_logprobs": teacher_logprobs_time,
+ "time/scoring": scoring_time,
"time/save_ckpt": save_ckpt_time,
"filters/all/is_filtered": results_df.is_filtered.astype(float).mean(),
**{f"filters/all/{name}": filter_df[name].astype(float).mean() for name in filter_df.columns},
diff --git a/src/prime_rl/orchestrator/orchestrator.py b/src/prime_rl/orchestrator/orchestrator.py
index cb8bbcf852..75616c2c93 100644
--- a/src/prime_rl/orchestrator/orchestrator.py
+++ b/src/prime_rl/orchestrator/orchestrator.py
@@ -41,6 +41,7 @@
import prime_rl._compat # noqa: F401 — patch ring_flash_attn compat before transitive imports
from prime_rl.configs.orchestrator import OrchestratorConfig
+from prime_rl.orchestrator.algo import finalize_batch
from prime_rl.orchestrator.ckpt import setup_ckpt_manager
from prime_rl.orchestrator.dispatcher import DispatcherMetrics, DispatcherMode, RolloutDispatcher
from prime_rl.orchestrator.envs import EvalEnvs, TrainEnvs
@@ -66,18 +67,17 @@
TrainRollout,
)
from prime_rl.orchestrator.utils import (
- compute_teacher_logprobs,
get_weight_dir,
intercept_vf_logging,
save_rollouts,
set_default_executor,
- setup_student_inference_pool,
+ setup_policy_inference_pool,
)
from prime_rl.orchestrator.watcher import WeightWatcher
from prime_rl.trainer.model import setup_tokenizer
from prime_rl.transport import TrainingBatch, setup_training_batch_sender
from prime_rl.utils.async_utils import safe_cancel
-from prime_rl.utils.client import init_nccl_broadcast, setup_inference_pool
+from prime_rl.utils.client import init_nccl_broadcast
from prime_rl.utils.heartbeat import Heartbeat
from prime_rl.utils.logger import format_time, get_logger, setup_logger
from prime_rl.utils.monitor import setup_monitor
@@ -129,7 +129,7 @@ class Orchestrator:
# Always set by ``setup()``
tokenizer: PreTrainedTokenizer
- student_inference: InferencePool
+ policy_inference: InferencePool
monitor: Monitor
sender: TrainingBatchSender
train_envs: TrainEnvs
@@ -144,7 +144,6 @@ class Orchestrator:
# Set by ``setup()`` only when relevant config is present
renderer: Renderer | None
mm_token_type_ids_mapping: dict[int, int] | None
- teacher_inference: InferencePool | None
heart: Heartbeat | None
usage_reporter: UsageReporter | None
inference_metrics: InferenceMetricsCollector | None
@@ -162,7 +161,8 @@ def __init__(self, config: OrchestratorConfig) -> None:
# ``verifiers.serve`` (env-server lifecycle) through our handler
logging.getLogger("verifiers").setLevel(logging.CRITICAL + 1)
intercept_vf_logging(logger="verifiers.serve", level="WARN")
- get_logger().info(f"Starting orchestrator ({config.training_mode})")
+ algorithms = sorted({env.algo.advantage.type for env in config.train.env if env.algo is not None})
+ get_logger().info(f"Starting orchestrator (algorithm: {', '.join(algorithms)})")
if config.bench:
get_logger().warning(f"Running in benchmark mode (max_steps={config.max_steps})")
@@ -186,7 +186,6 @@ def __init__(self, config: OrchestratorConfig) -> None:
# config is present
self.renderer = None
self.mm_token_type_ids_mapping = None
- self.teacher_inference = None
self.heart = None
self.usage_reporter = None
self.inference_metrics = None
@@ -220,12 +219,14 @@ async def setup(self) -> None:
get_logger().info(f"Initializing tokenizer ({config.tokenizer})")
self.tokenizer = setup_tokenizer(config.tokenizer)
- # Student inference pool
+ # The one model prime-rl hosts: the live policy. Frozen model
+ # references are external endpoints — each env's Algorithm builds its
+ # own pools in ``setup()`` below.
get_logger().info(
- f"Initializing student inference pool (base_url={', '.join(config.student.client.base_url)}, "
- f"model={config.student.model.name})"
+ f"Initializing policy inference pool (base_url={', '.join(config.model.client.base_url)}, "
+ f"model={config.model.name})"
)
- self.renderer, self.student_inference = await setup_student_inference_pool(
+ self.renderer, self.policy_inference = await setup_policy_inference_pool(
config=config, tokenizer=self.tokenizer
)
self.mm_token_type_ids_mapping = (
@@ -234,17 +235,6 @@ async def setup(self) -> None:
if self.mm_token_type_ids_mapping == {}:
self.mm_token_type_ids_mapping = None
- if config.teacher is not None:
- get_logger().info(
- f"Initializing teacher inference pool (base_url={', '.join(config.teacher.client.base_url)}, "
- f"model={config.teacher.model.name})"
- )
- self.teacher_inference = await setup_inference_pool(
- config.teacher.client,
- model_name=config.teacher.model.name,
- train_client_type="openai_chat_completions",
- )
-
get_logger().info(f"Initializing monitor (wandb={config.wandb}, prime_monitor={config.prime_monitor})")
self.monitor = setup_monitor(
wandb_config=config.wandb,
@@ -268,10 +258,9 @@ async def setup(self) -> None:
post_filters = setup_filters(config.post_batch_filters, vocab_size=self.tokenizer.vocab_size, kind="post-batch")
get_logger().info("Loading training environments")
- self.train_envs = TrainEnvs(config.train.env)
- if config.training_mode == "sft":
- for env in self.train_envs:
- env.sampling_args.pop("logprobs", None)
+ self.train_envs = TrainEnvs(
+ config.train.env, policy_pool=self.policy_inference, renderer=self.renderer, max_seq_len=config.seq_len
+ )
get_logger().debug(
f"Loaded {len(self.train_envs)} training environment(s) ({', '.join(self.train_envs.names)})"
)
@@ -300,20 +289,21 @@ async def setup(self) -> None:
self.resume_step = config.ckpt.resume_step
# Resume below may bump ``policy.version`` and the LoRA model name
- self.policy.model_name = self.student_inference.model_name
-
- get_logger().info("Waiting for student inference pool to be ready")
- await self.student_inference.wait_for_ready(config.student.model.name)
- get_logger().success("Student inference pool ready")
- if self.teacher_inference is not None:
- assert config.teacher is not None
- get_logger().info("Waiting for teacher inference pool to be ready")
- await self.teacher_inference.wait_for_ready(config.teacher.model.name)
- get_logger().success("Teacher inference pool ready")
+ self.policy.model_name = self.policy_inference.model_name
+
+ get_logger().info("Waiting for policy inference pool to be ready")
+ await self.policy_inference.wait_for_ready(config.model.name)
+ get_logger().success("Policy inference pool ready")
+ # Build + ready pools for each env's frozen sampling source and the
+ # algorithm's frozen reference model
+ await asyncio.gather(
+ *(env.sampler.setup() for env in self.train_envs),
+ *(env.algorithm.setup() for env in self.train_envs),
+ )
if config.wandb is not None and config.collect_inference_metrics:
self.inference_metrics = InferenceMetricsCollector(
- self.student_inference.admin_clients,
+ self.policy_inference.admin_clients,
roles=config.inference_metrics_roles,
)
await self.inference_metrics.start()
@@ -321,7 +311,7 @@ async def setup(self) -> None:
get_logger().info(f"Initializing weight broadcast ({config.weight_broadcast})")
if config.weight_broadcast.type == "nccl":
await init_nccl_broadcast(
- self.student_inference.admin_clients,
+ self.policy_inference.admin_clients,
config.weight_broadcast.host,
config.weight_broadcast.port,
config.weight_broadcast.timeout,
@@ -332,7 +322,7 @@ async def setup(self) -> None:
get_logger().info(f"Initializing training batch sender ({config.rollout_transport})")
self.sender = setup_training_batch_sender(config.output_dir, config.rollout_transport)
- self.lora_name = config.student.model.lora.name if config.student.model.lora else None
+ self.lora_name = config.model.lora.name if config.model.lora else None
if self.resume_step is not None and self.ckpt_manager is not None:
self.ckpt_manager.load(self.progress, step=self.resume_step)
@@ -342,23 +332,14 @@ async def setup(self) -> None:
weights_path = get_weight_dir(
config.output_dir, self.progress.step, check_exists=check_exists, wait_timeout=wait_timeout
)
- await self.student_inference.update_weights(weights_path, lora_name=self.lora_name, step=self.progress.step)
+ await self.policy_inference.update_weights(weights_path, lora_name=self.lora_name, step=self.progress.step)
if self.lora_name is not None:
- self.student_inference.update_model_name(self.lora_name)
+ self.policy_inference.update_model_name(self.lora_name)
self.policy.model_name = self.lora_name
self.policy.version = self.progress.step
else:
get_logger().info("Training from scratch")
- # SFT train rollouts come from the teacher when configured; otherwise
- # they use the existing student rollout pool.
- if config.training_mode == "sft" and self.teacher_inference is not None:
- rollout_inference = self.teacher_inference
- use_cache_salt = False
- else:
- rollout_inference = self.student_inference
- use_cache_salt = True
-
self.train_source = TrainSource(self.train_envs, seed=42)
self.eval_source: EvalSource | None = (
EvalSource(
@@ -378,14 +359,11 @@ async def setup(self) -> None:
eval_envs=self.eval_envs,
train_source=self.train_source,
eval_source=self.eval_source,
- inference=rollout_inference,
- eval_inference=self.student_inference,
+ policy_pool=self.policy_inference,
policy=self.policy,
max_inflight_rollouts=config.max_inflight_rollouts,
tasks_per_minute=config.tasks_per_minute,
max_off_policy_steps=config.max_off_policy_steps,
- training_mode=config.training_mode,
- use_cache_salt=use_cache_salt,
)
self.metrics = MetricsBuilder(config, start_step=self.progress.step)
self.train_sink = TrainSink(
@@ -403,7 +381,7 @@ async def setup(self) -> None:
self.watcher = WeightWatcher(
config,
policy=self.policy,
- inference=self.student_inference,
+ inference=self.policy_inference,
observers=[self.dispatcher, self],
lora_name=self.lora_name,
ckpt_step=self.progress.step,
@@ -547,7 +525,7 @@ async def _drain_token_export_metrics(self) -> None:
async def finalize_train_batch(self, batch: TrainBatch) -> None:
"""Ship one ``TrainBatch`` out to the trainer and handle the I/O
- side-effects (ckpt, save_rollouts, teacher logprobs, sender.send,
+ side-effects (ckpt, save_rollouts, reference scoring, sender.send,
metrics, heartbeat, progress, eval trigger). The sink has already
done all data-transformation work."""
config = self.config
@@ -599,18 +577,11 @@ async def finalize_train_batch(self, batch: TrainBatch) -> None:
save_rollouts, rollout_dicts, step_path / "train_rollouts.jsonl", exclude_keys={"trajectory"}
)
- teacher_logprobs_time = 0.0 # opd only
- if config.training_mode == "opd" and self.teacher_inference is not None:
- assert config.teacher is not None
- t = time.perf_counter()
- teacher_logprobs_list = await compute_teacher_logprobs(
- clients=self.teacher_inference.train_clients,
- model_name=config.teacher.model.name,
- samples=batch.samples,
- )
- for ex, lp in zip(batch.samples, teacher_logprobs_list):
- ex.teacher_logprobs = lp
- teacher_logprobs_time = time.perf_counter() - t
+ # Per-env reference scoring runs at the batch boundary; envs without a
+ # reference are a no-op, so this is unconditional.
+ t = time.perf_counter()
+ await finalize_batch(self.train_envs, batch.rollouts)
+ scoring_time = time.perf_counter() - t
await self.sender.send(TrainingBatch(examples=batch.samples, step=step))
self.update_dispatch_gate()
@@ -622,7 +593,7 @@ async def finalize_train_batch(self, batch: TrainBatch) -> None:
progress=self.progress,
step_time=step_time,
save_ckpt_time=save_ckpt_time,
- teacher_logprobs_time=teacher_logprobs_time,
+ scoring_time=scoring_time,
pre_filter_seen=self.train_sink.pre_filter_seen,
pre_filter_dropped=self.train_sink.pre_filter_dropped,
pre_filter_dropped_by_name=dict(self.train_sink.pre_filter_dropped_by_name),
@@ -632,7 +603,8 @@ async def finalize_train_batch(self, batch: TrainBatch) -> None:
self.monitor.log_distributions(
distributions={
"rewards": [r.reward for r in batch.rollouts],
- "advantages": [r.advantage for r in batch.rollouts if r.advantage is not None],
+ # Scalar view of the per-token streams (exact for uniform streams)
+ "advantages": [sum(r.advantages) / len(r.advantages) for r in batch.rollouts if r.advantages],
},
step=step,
)
@@ -903,11 +875,12 @@ async def teardown() -> None:
self.component_tasks.clear()
if self.inference_metrics is not None:
await self.inference_metrics.stop()
- if self.student_inference is not None:
- await self.student_inference.stop()
- if self.teacher_inference is not None:
- await self.teacher_inference.stop()
+ if getattr(self, "policy_inference", None) is not None:
+ await self.policy_inference.stop()
if self.train_envs is not None:
+ for env in self.train_envs:
+ for pool in (*env.sampler.connected_pools, *env.algorithm.connected_pools):
+ await pool.stop()
self.train_envs.shutdown()
if self.eval_envs is not None:
self.eval_envs.shutdown()
diff --git a/src/prime_rl/orchestrator/sampler.py b/src/prime_rl/orchestrator/sampler.py
new file mode 100644
index 0000000000..e57e7c3cbf
--- /dev/null
+++ b/src/prime_rl/orchestrator/sampler.py
@@ -0,0 +1,52 @@
+"""How an env's train rollouts are produced — the sample strategy.
+
+The algorithm (``algo/``) consumes finalized rollouts and compiles them into
+per-token loss-component weights; the sampler owns where those rollouts come
+from. Today that is one question — which model generates them — and its
+consequences (sampling logprobs, prefix-cache salting, and off-policy
+staleness are all liveness questions about the source). Future sampling
+strategies (replay buffers, branching) extend here, not the algorithm.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prime_rl.configs.algorithm import FrozenModelConfig, SamplingConfig
+from prime_rl.orchestrator.algo import connect_frozen_pool
+
+if TYPE_CHECKING:
+ from prime_rl.utils.client import InferencePool
+
+
+class Sampler:
+ """One env's rollout source.
+
+ ``pool`` is the pool train rollouts are generated from: the policy pool,
+ swapped for a connected frozen pool in :meth:`setup` when the source is an
+ inline frozen model."""
+
+ def __init__(self, config: SamplingConfig, policy_pool: InferencePool):
+ assert config.source is not None, "sampling.source must be resolved by config validation"
+ self.config = config
+ self.pool: InferencePool = policy_pool
+ self.connected_pools: list[InferencePool] = [] # client pools connected in setup(); closed at shutdown
+
+ async def setup(self) -> None:
+ """Connect a client pool to a frozen sampling source and wait for
+ readiness. Must run before dispatching."""
+ if isinstance(self.config.source, FrozenModelConfig):
+ self.pool = await connect_frozen_pool(self.config.source)
+ self.connected_pools.append(self.pool)
+
+ @property
+ def samples_from_live_policy(self) -> bool:
+ return self.config.source == "policy"
+
+ def sampling_args(self, args: dict) -> dict:
+ """Source-specific sampling-arg overrides. Sampling logprobs are only
+ needed for importance ratios on policy-sampled tokens — frozen
+ endpoints may reject the knob."""
+ if not self.samples_from_live_policy:
+ args.pop("logprobs", None)
+ return args
diff --git a/src/prime_rl/orchestrator/train_sink.py b/src/prime_rl/orchestrator/train_sink.py
index f79a0d5eff..66926ad51b 100644
--- a/src/prime_rl/orchestrator/train_sink.py
+++ b/src/prime_rl/orchestrator/train_sink.py
@@ -2,13 +2,14 @@
1. ``process_rollout`` — eager per-rollout tokenization (overlaps with
dispatcher producing more rollouts). Errored rollouts skip this.
-2. ``process_group`` — filters errored rollouts, computes advantages over
- survivors, runs the pre-batch filter pass.
+2. ``process_group`` — filters errored rollouts, hands survivors to the env
+ algorithm's ``finalize_group`` (advantages + per-sample wire stamping),
+ runs the pre-batch filter pass.
3. ``process_batch`` — applies post-batch filter annotations and assembles
the trainer-bound ``TrainingSample`` list. Returns a ``TrainBatch``.
``add()`` returns ``TrainBatch | None``. I/O concerns (ship to trainer,
-save_rollouts, monitor.log, teacher logprobs) live on the orchestrator.
+save_rollouts, monitor.log, reference scoring) live on the orchestrator.
"""
from __future__ import annotations
@@ -18,7 +19,7 @@
from collections import defaultdict
from prime_rl.configs.orchestrator import OrchestratorConfig
-from prime_rl.orchestrator.advantage import assign_advantages
+from prime_rl.orchestrator.algo import finalize_group, finalize_rollout
from prime_rl.orchestrator.envs import TrainEnvs
from prime_rl.orchestrator.filters import RolloutFilter, apply_filters
from prime_rl.orchestrator.trajectories import (
@@ -132,7 +133,7 @@ async def add(self, rollout: TrainRollout) -> TrainBatch | None:
self.errors_by_env[env_name] += 1
self.pending_groups[rollout.group_id].append(rollout)
if len(self.pending_groups[rollout.group_id]) >= self.group_size_for(env_name):
- self.process_group(rollout.group_id)
+ await self.process_group(rollout.group_id)
ready = (
len(self.pending_batch) >= self.batch_size
if self.batch_size is not None
@@ -148,7 +149,7 @@ async def add(self, rollout: TrainRollout) -> TrainBatch | None:
async def process_rollout(self, rollout: TrainRollout) -> None:
"""Tokenize the rollout eagerly. Backfills tokens if the env didn't
- return them (SFT against external teacher APIs); errored rollouts
+ return them (frozen-sourced rollouts from external APIs); errored rollouts
skip tokenization and get dropped at the group level."""
if rollout.error is not None:
return
@@ -157,18 +158,23 @@ async def process_rollout(self, rollout: TrainRollout) -> None:
if needs_backfill:
await asyncio.to_thread(backfill_rollout_tokens, raw, self.tokenizer, renderer=self.renderer)
samples = await asyncio.to_thread(
- interleave_rollout,
- raw,
- mm_token_type_ids_mapping=self.mm_token_type_ids_mapping,
- env_name=rollout.env_name,
+ lambda: interleave_rollout(
+ raw,
+ env_name=rollout.env_name,
+ mm_token_type_ids_mapping=self.mm_token_type_ids_mapping,
+ )
)
rollout.samples = samples or []
+ # Arrival phase: rollout-local scoring (raw reward, echo observation
+ # weighting) runs as soon as the rollout is tokenized — before its
+ # group is complete.
+ await finalize_rollout(self.train_envs.get(rollout.env_name).algorithm, rollout)
# Offload base64 image bytes to disk as soon as the rollout is
# tokenized, so memory stays flat instead of holding every buffered
# rollout's images until the batch ships (no-op for text-only).
await asyncio.to_thread(offload_images_to_disk, [raw], self.config.output_dir)
- def process_group(self, group_id: uuid.UUID) -> None:
+ async def process_group(self, group_id: uuid.UUID) -> None:
"""Finalize one GRPO group: drop errored rollouts (the whole group
when ``requires_group_scoring`` and any failed), assign advantages,
run pre-batch filters, append survivors to ``pending_batch``."""
@@ -196,19 +202,16 @@ def process_group(self, group_id: uuid.UUID) -> None:
)
return
- assign_advantages(survivors, self.train_envs.get(env_name).advantage_fn)
+ # Advantages + per-sample wire stamping (advantage stream, loss
+ # routing) are the algorithm's job; the sink only owns the grouping
+ # mechanics.
+ await finalize_group(env.algorithm, survivors)
- # Propagate to the pre-tokenized samples so the orchestrator can
- # collect samples at ship time without re-walking rollouts. The env
- # has a single sampling temperature; fan it out across each sample's
- # completion tokens here (interleave leaves it empty).
+ # The env has a single sampling temperature; fan it out across each
+ # sample's completion tokens (interleave leaves it empty).
temperature = env.sampling_args["temperature"]
for r in survivors:
for sample in r.samples:
- sample.advantage = r.advantage
- sample.reward = r.reward
- sample.env_name = r.env_name
- sample.training_mode = self.config.training_mode
sample.completion_temperatures = [temperature] * len(sample.completion_ids)
if self.pre_filters:
@@ -266,7 +269,7 @@ def process_batch(self) -> TrainBatch:
apply_filters(self.post_filters, cohort)
# Samples are pre-built by ``process_rollout``; ``process_group``
- # already set advantage/reward on each sample
+ # already set advantages/reward on each sample
samples: list[TrainingSample] = []
prefill_lens: list[int] = []
decode_lens: list[int] = []
diff --git a/src/prime_rl/orchestrator/trajectories.py b/src/prime_rl/orchestrator/trajectories.py
index 3e8431c12a..5b62c26b89 100644
--- a/src/prime_rl/orchestrator/trajectories.py
+++ b/src/prime_rl/orchestrator/trajectories.py
@@ -222,6 +222,13 @@ def interleave_rollout(
Returns a list of samples - could be 1 (extension always held) or up to T
(extension never held).
+ Env-provided observation tokens (the spans that land as later-turn prompt
+ extensions) are recorded as provenance on each sample: ``obs_spans``
+ entries map sample completion positions back to trajectory-step
+ coordinates (``[completion_start, step_idx, step_prompt_start, length]``).
+ Algorithms that train on observations (ECHO) consume the spans at group
+ time to write per-token ce weights; everything else ignores them.
+
For VLM models, each renderer-produced trajectory step carries its
per-image processed tensors inline on ``multi_modal_data``; the last
merged step's sidecar covers every image in the sample.
@@ -265,6 +272,9 @@ def prepare_step_tokens(step: vf.TrajectoryStep, step_idx: int) -> dict[str, Any
# a multimodal-aware renderer (e.g. Qwen3VLRenderer); absent
# for text-only rollouts.
"multi_modal_data": tokens.get("multi_modal_data"),
+ # Renderer per-token attribution (message_indices / roles /
+ # is_content); absent on MITO rollouts.
+ "prompt_attribution": tokens.get("prompt_attribution"),
}
logger.warning(f"Missing rollout tokens for example {output['example_id']} step {step_idx}.")
@@ -303,8 +313,7 @@ def make_sample(tokens: dict[str, Any], step_idx: int) -> TrainingSample:
completion_mask=completion_mask,
completion_logprobs=list(tokens["completion_logprobs"]),
completion_temperatures=[],
- teacher_logprobs=None,
- advantage=None,
+ ref_logprobs=None,
env_name=env_name,
mm_token_type_ids=None,
routed_experts=None, # deferred — finalized at end of interleave_rollout
@@ -370,8 +379,15 @@ def extend_sample(
"""Extend an existing sample with a new trajectory step (extension property holds)."""
tokens = prepared_steps[step_idx]
- # Extend with new prompt tokens (mask=False, no gradient)
+ # Extend with new prompt tokens (mask=False, no gradient). These are
+ # the env's response to the previous action — observation tokens;
+ # record where they came from so group-time observation weighting
+ # (echo) can look up the step's attribution.
new_prompt_ids = tokens["prompt_ids"][prefix_len:]
+ if new_prompt_ids:
+ if sample.obs_spans is None:
+ sample.obs_spans = []
+ sample.obs_spans.append([len(sample.completion_ids), step_idx, prefix_len, len(new_prompt_ids)])
sample.completion_ids.extend(new_prompt_ids)
sample.completion_mask.extend([False] * len(new_prompt_ids))
sample.completion_logprobs.extend([0.0] * len(new_prompt_ids))
diff --git a/src/prime_rl/orchestrator/types.py b/src/prime_rl/orchestrator/types.py
index c2a3f5de79..ad9b694bda 100644
--- a/src/prime_rl/orchestrator/types.py
+++ b/src/prime_rl/orchestrator/types.py
@@ -97,7 +97,8 @@ def to_dict(self) -> vf.RolloutOutput:
``monitor.log_samples``). Shallow copy; never mutates ``self.raw``."""
out: vf.RolloutOutput = dict(self.raw) # type: ignore[assignment]
for f in fields(self):
- if f.name in ("raw", "samples"):
+ # advantages is per-token bulk data like samples — skip it
+ if f.name in ("raw", "samples", "advantages"):
continue
val = getattr(self, f.name)
if f.name == "filter_results":
@@ -110,10 +111,74 @@ def to_dict(self) -> vf.RolloutOutput:
@dataclass
class TrainRollout(FinishedRollout):
samples: list[TrainingSample] = field(default_factory=list)
- advantage: float | None = None
+ # Per-token advantages from the advantage strategy, aligned to the
+ # samples' completion tokens (concatenated in step order). None = no
+ # credit assigned (advantage-based filters skip it; the wire ships no
+ # advantage stream).
+ advantages: list[float] | None = None
is_filtered: bool = False
filter_results: dict[str, bool] = field(default_factory=dict)
+ def to_dict(self) -> vf.RolloutOutput:
+ out = super().to_dict()
+ # ``advantages`` is skipped as bulk; dumps keep a scalar view (exact
+ # for uniform streams, the mean otherwise).
+ if self.advantages:
+ out["advantage"] = sum(self.advantages) / len(self.advantages)
+ return out
+
+
+@dataclass(frozen=True)
+class RolloutView:
+ """A finalized rollout as a writable handle — the single currency the
+ scoring hooks operate on. Exposes what the env produced (``raw``), the
+ samples interleaving built (``samples``, carrying ``obs_spans``), and the
+ rollout's identity/reward; credit is written through
+ :meth:`assign_advantages`, which spreads over the samples' completion
+ tokens. Deliberately does *not* expose pipeline-internal lifecycle fields
+ (``is_filtered``, ``filter_results``, ``group_id``) or not-yet-assigned
+ credit (``advantages``) — a hook can only touch what is valid at its
+ stage."""
+
+ _rollout: TrainRollout
+
+ @property
+ def raw(self) -> vf.RolloutOutput:
+ return self._rollout.raw
+
+ @property
+ def samples(self) -> list[TrainingSample]:
+ return self._rollout.samples
+
+ @property
+ def reward(self) -> float:
+ return self._rollout.reward
+
+ @property
+ def env_name(self) -> str:
+ return self._rollout.env_name
+
+ @property
+ def example_id(self) -> int | str:
+ return self._rollout.example_id
+
+ def assign_advantages(self, values: float | list[float]) -> None:
+ """Write the rl advantage stream: a scalar broadcast over the
+ rollout's completion tokens, or a per-token list aligned to them
+ (concatenated across samples in step order). Prompt positions are
+ padded at stamping; a rollout never assigned ships no advantage
+ stream."""
+ total = sum(len(sample.completion_ids) for sample in self._rollout.samples)
+ if isinstance(values, (int, float)):
+ self._rollout.advantages = [float(values)] * total
+ return
+ if len(values) != total:
+ raise ValueError(
+ f"per-token advantages must align with the rollout's completion tokens: "
+ f"got {len(values)}, expected {total} (env '{self._rollout.env_name}')."
+ )
+ self._rollout.advantages = [float(v) for v in values]
+
@dataclass
class EvalRollout(FinishedRollout):
diff --git a/src/prime_rl/orchestrator/utils.py b/src/prime_rl/orchestrator/utils.py
index 5675ba3f34..810787b423 100644
--- a/src/prime_rl/orchestrator/utils.py
+++ b/src/prime_rl/orchestrator/utils.py
@@ -2,7 +2,6 @@
import logging
import time
from concurrent.futures import ThreadPoolExecutor
-from itertools import cycle
from pathlib import Path
import orjson
@@ -11,7 +10,6 @@
from verifiers.utils.save_utils import make_serializable
from prime_rl.configs.orchestrator import OrchestratorConfig
-from prime_rl.transport import TrainingSample
from prime_rl.utils.client import setup_inference_pool
from prime_rl.utils.logger import InterceptHandler, get_logger
from prime_rl.utils.utils import (
@@ -21,18 +19,29 @@
)
-async def setup_student_inference_pool(*, config: OrchestratorConfig, tokenizer):
- """Build the student inference pool + matching renderer. Returns
- ``(renderer | None, inference_pool)``; ``renderer`` is ``None`` on the
- MITO path (``config.renderer is None``)."""
+async def setup_policy_inference_pool(*, config: OrchestratorConfig, tokenizer):
+ """Build the live policy inference pool + matching renderer. Returns
+ ``(renderer | None, inference_pool)``; ``renderer`` is ``None`` only when
+ the run opts out (``config.renderer is None``, MITO).
+
+ The renderer object and the renderer *client* are decoupled: the object
+ is the canonical messages → token ids path (sft backfill, hinted scoring
+ prefixes, role attribution) and exists whenever configured; the
+ renderer-client sampling path is wired onto the pool only when a train
+ env actually samples from the policy. Frozen-sourced runs keep the
+ renderer for tokenization while the policy pool serves plain
+ chat-completions (evals)."""
from renderers.base import create_renderer
- client_config = config.student.client
- model_name = config.student.model.name
+ client_config = config.model.client
+ model_name = config.model.name
+ renderer = None
if config.renderer is not None:
renderer = create_renderer(tokenizer, config.renderer)
get_logger().info(f"Initialized {type(renderer).__name__} for {model_name}")
+
+ if renderer is not None and config.any_policy_sourced:
inference_pool = await setup_inference_pool(
client_config,
model_name=model_name,
@@ -44,14 +53,17 @@ async def setup_student_inference_pool(*, config: OrchestratorConfig, tokenizer)
get_logger().info("Using direct renderer rollout client")
return renderer, inference_pool
- get_logger().info("Using MITO (openai_chat_completions) for rollouts")
+ if renderer is not None:
+ get_logger().info("No policy-sourced train env — renderer kept for client-side tokenization only")
+ else:
+ get_logger().info("Using MITO (openai_chat_completions) for rollouts")
inference_pool = await setup_inference_pool(
client_config,
model_name=model_name,
train_client_type="openai_chat_completions",
eval_client_type="openai_chat_completions",
)
- return None, inference_pool
+ return renderer, inference_pool
def get_model_completion_len(output: vf.RolloutOutput) -> int:
@@ -96,60 +108,58 @@ def set_default_executor(max_workers: int = 64) -> None:
asyncio.get_event_loop().set_default_executor(ThreadPoolExecutor(max_workers=max_workers))
-async def compute_teacher_logprobs(
- clients: list[vf.ClientConfig],
+async def compute_prefill_logprobs(
+ client_config: vf.ClientConfig,
model_name: str,
- samples: list[TrainingSample],
-) -> list[list[float]]:
- """Compute teacher model logprobs for a batch of training samples via prefill."""
+ token_ids: list[int],
+) -> list[float]:
+ """Score ``token_ids`` under ``model_name`` via prefill; returns one
+ logprob per token (0.0 for the leading token, which has no context)."""
import httpx
from vllm.entrypoints.serve.disagg.protocol import GenerateResponse
- async def _compute_single(client_config: vf.ClientConfig, sample: TrainingSample) -> list[float]:
- client = setup_openai_client(client_config)
-
- # Two escape hatches from ``AsyncOpenAI.post``:
- # 1. URL — ``/inference/v1/generate`` is mounted at server root, not
- # under ``/v1``. Pass an absolute URL so the SDK's
- # ``_prepare_url`` skips the base-url merge (it short-circuits
- # when the path passes ``httpx.URL.is_relative_url`` as False).
- # 2. Parse — vLLM's ``GenerateResponse`` is a plain
- # ``pydantic.BaseModel`` and the SDK's parse layer rejects any
- # ``cast_to`` that doesn't subclass ``openai.BaseModel``. Use
- # ``cast_to=httpx.Response`` so the SDK still builds the request
- # (preserving ``auth_headers``, retries, timeouts, idempotency
- # keys) and just hands us the raw response to validate ourselves.
- base = str(client.base_url).rstrip("/").removesuffix("/v1")
- http_response = await client.post(
- f"{base}/inference/v1/generate",
- cast_to=httpx.Response,
- body={
- "model": model_name,
- "token_ids": list(sample.prompt_ids) + list(sample.completion_ids),
- "sampling_params": {
- "max_tokens": 1,
- "temperature": 1.0,
- "top_p": 1.0,
- "prompt_logprobs": 1,
- },
+ client = setup_openai_client(client_config)
+
+ # Two escape hatches from ``AsyncOpenAI.post``:
+ # 1. URL — ``/inference/v1/generate`` is mounted at server root, not
+ # under ``/v1``. Pass an absolute URL so the SDK's
+ # ``_prepare_url`` skips the base-url merge (it short-circuits
+ # when the path passes ``httpx.URL.is_relative_url`` as False).
+ # 2. Parse — vLLM's ``GenerateResponse`` is a plain
+ # ``pydantic.BaseModel`` and the SDK's parse layer rejects any
+ # ``cast_to`` that doesn't subclass ``openai.BaseModel``. Use
+ # ``cast_to=httpx.Response`` so the SDK still builds the request
+ # (preserving ``auth_headers``, retries, timeouts, idempotency
+ # keys) and just hands us the raw response to validate ourselves.
+ base = str(client.base_url).rstrip("/").removesuffix("/v1")
+ http_response = await client.post(
+ f"{base}/inference/v1/generate",
+ cast_to=httpx.Response,
+ body={
+ "model": model_name,
+ "token_ids": token_ids,
+ "sampling_params": {
+ "max_tokens": 1,
+ "temperature": 1.0,
+ "top_p": 1.0,
+ "prompt_logprobs": 1,
},
- )
- response = GenerateResponse.model_validate_json(http_response.content)
- # ``prompt_logprobs[i]`` is a ``{token_id: Logprob}`` dict for tokens
- # the engine could score, or ``None`` for the leading token which has
- # no preceding context. Flatten to ``list[float]`` with 0.0 in the
- # unscored slot.
- flat: list[float] = []
- for entry in response.prompt_logprobs or []:
- if not entry:
- flat.append(0.0)
- continue
- first = next(iter(entry.values()))
- lp = first.logprob if hasattr(first, "logprob") else first.get("logprob")
- flat.append(float(lp) if lp is not None else 0.0)
- return flat
-
- return await asyncio.gather(*[_compute_single(client, sample) for client, sample in zip(cycle(clients), samples)])
+ },
+ )
+ response = GenerateResponse.model_validate_json(http_response.content)
+ # ``prompt_logprobs[i]`` is a ``{token_id: Logprob}`` dict for tokens
+ # the engine could score, or ``None`` for the leading token which has
+ # no preceding context. Flatten to ``list[float]`` with 0.0 in the
+ # unscored slot.
+ flat: list[float] = []
+ for entry in response.prompt_logprobs or []:
+ if not entry:
+ flat.append(0.0)
+ continue
+ first = next(iter(entry.values()))
+ lp = first.logprob if hasattr(first, "logprob") else first.get("logprob")
+ flat.append(float(lp) if lp is not None else 0.0)
+ return flat
def get_weight_dir(output_dir: Path, step: int, check_exists: bool = True, wait_timeout: int | None = None) -> Path:
diff --git a/src/prime_rl/trainer/batch.py b/src/prime_rl/trainer/batch.py
index 2c39a83e4c..059d850fad 100644
--- a/src/prime_rl/trainer/batch.py
+++ b/src/prime_rl/trainer/batch.py
@@ -11,6 +11,11 @@
"int32": 4,
}
+# Backfill value per component weight stream when a packed sample doesn't
+# carry it: absent rl means weight 1.0 on the loss mask, absent ce/ref_kl
+# means no component (weight 0.0).
+STREAM_FILL = {"rl_weights": 1.0, "ce_weights": 0.0, "ref_kl_weights": 0.0}
+
def _copy_routed_experts(routed_experts: RoutedExperts) -> RoutedExperts:
return RoutedExperts(
@@ -49,7 +54,22 @@ def prepare_sample(training_example: TrainingSample, seq_len: int) -> MicroBatch
input_ids = training_example.prompt_ids + training_example.completion_ids
loss_mask = training_example.prompt_mask + training_example.completion_mask
inference_logprobs = [0.0] * len(training_example.prompt_ids) + training_example.completion_logprobs
- advantages = [training_example.advantage] * len(input_ids)
+ if training_example.advantages is not None:
+ advantages = list(training_example.advantages)
+ else:
+ rl_w = training_example.rl_weights
+ has_rl_members = any(loss_mask) if rl_w is None else any(m and w != 0 for m, w in zip(loss_mask, rl_w))
+ if has_rl_members:
+ raise ValueError(
+ f"sample from env '{training_example.env_name}' has rl member tokens but no advantages — "
+ "the producer must stamp the advantage stream (the orchestrator broadcasts the rollout scalar)"
+ )
+ advantages = [0.0] * len(input_ids)
+ # Component weight streams: keep absent streams None (rl weight 1.0 on the
+ # loss mask, no ce/ref_kl component) so the packed batch stays as small as before.
+ rl_weights = list(training_example.rl_weights) if training_example.rl_weights is not None else None
+ ce_weights = list(training_example.ce_weights) if training_example.ce_weights is not None else None
+ ref_kl_weights = list(training_example.ref_kl_weights) if training_example.ref_kl_weights is not None else None
reward = training_example.reward if training_example.reward is not None else float("nan")
rewards = [reward] * len(input_ids)
position_ids = list(range(len(input_ids)))
@@ -62,9 +82,9 @@ def prepare_sample(training_example: TrainingSample, seq_len: int) -> MicroBatch
prompt_temp = training_example.completion_temperatures[0] if training_example.completion_temperatures else 1.0
temperatures = [prompt_temp] * len(training_example.prompt_ids) + training_example.completion_temperatures
- # Teacher logprobs already cover the full sequence (prompt + completion),
- # computed via prefill in the orchestrator when a teacher model is configured
- teacher_logprobs = training_example.teacher_logprobs
+ # Ref logprobs already cover the full sequence (prompt + completion),
+ # computed via prefill in the orchestrator when the algorithm scores against a reference
+ ref_logprobs = training_example.ref_logprobs
routed_experts = (
_copy_routed_experts(training_example.routed_experts) if training_example.routed_experts is not None else None
)
@@ -77,8 +97,14 @@ def prepare_sample(training_example: TrainingSample, seq_len: int) -> MicroBatch
advantages = advantages[:seq_len]
rewards = rewards[:seq_len]
temperatures = temperatures[:seq_len]
- if teacher_logprobs is not None:
- teacher_logprobs = teacher_logprobs[:seq_len]
+ if ref_logprobs is not None:
+ ref_logprobs = ref_logprobs[:seq_len]
+ if rl_weights is not None:
+ rl_weights = rl_weights[:seq_len]
+ if ce_weights is not None:
+ ce_weights = ce_weights[:seq_len]
+ if ref_kl_weights is not None:
+ ref_kl_weights = ref_kl_weights[:seq_len]
if routed_experts is not None:
routed_experts = _slice_routed_experts(routed_experts, seq_len)
if mm_token_type_ids is not None:
@@ -96,8 +122,15 @@ def prepare_sample(training_example: TrainingSample, seq_len: int) -> MicroBatch
), (
f"input_ids: {len(input_ids)}, advantages: {len(advantages)}, loss_mask: {len(loss_mask)}, position_ids: {len(position_ids)}, inference_logprobs: {len(inference_logprobs)}, rewards: {len(rewards)}, temperatures: {len(temperatures)}"
)
- if teacher_logprobs is not None:
- assert len(teacher_logprobs) == len(input_ids), f"teacher_logprobs: {len(teacher_logprobs)}"
+ if ref_logprobs is not None:
+ assert len(ref_logprobs) == len(input_ids), f"ref_logprobs: {len(ref_logprobs)}"
+ for stream_name, stream in (
+ ("rl_weights", rl_weights),
+ ("ce_weights", ce_weights),
+ ("ref_kl_weights", ref_kl_weights),
+ ):
+ if stream is not None:
+ assert len(stream) == len(input_ids), f"{stream_name}: {len(stream)}"
if routed_experts is not None:
assert routed_experts.shape[0] == len(input_ids), (
@@ -118,14 +151,16 @@ def prepare_sample(training_example: TrainingSample, seq_len: int) -> MicroBatch
position_ids=position_ids,
inference_logprobs=inference_logprobs,
sequence_lengths=[len(input_ids)],
- teacher_logprobs=teacher_logprobs,
+ ref_logprobs=ref_logprobs,
temperatures=temperatures,
rewards=rewards,
routed_experts=routed_experts,
mm_token_type_ids=mm_token_type_ids,
env_names=env_names,
mm_kwargs=training_example.mm_kwargs,
- training_mode=training_example.training_mode,
+ rl_weights=rl_weights,
+ ce_weights=ce_weights,
+ ref_kl_weights=ref_kl_weights,
)
@@ -148,12 +183,14 @@ def first_sample(self) -> MicroBatch:
return self.samples[0][1]
def can_add(self, sample: MicroBatch, max_seq_len: int) -> bool:
+ # Loss routing is per token (component weight streams), so samples of
+ # different loss types pack together freely — only modality, length and
+ # routed-experts presence constrain packing.
first_sample = self.first_sample
return (
not _is_multimodal_sample(first_sample)
and not _is_multimodal_sample(sample)
and self.length + len(sample.input_ids) <= max_seq_len
- and first_sample.training_mode == sample.training_mode
and (first_sample.routed_experts is None) == (sample.routed_experts is None)
)
@@ -186,8 +223,11 @@ def split_by_workload(self, bin_cost: Callable[[Sequence[int]], int]) -> tuple["
def _materialize_bin(bin_content: _MicroBatchBin, num_loras: int) -> MicroBatch:
has_rewards = any(sample.rewards is not None for _, sample in bin_content.samples)
- has_teacher_logprobs = any(sample.teacher_logprobs is not None for _, sample in bin_content.samples)
+ has_ref_logprobs = any(sample.ref_logprobs is not None for _, sample in bin_content.samples)
has_mm_token_type_ids = any(sample.mm_token_type_ids is not None for _, sample in bin_content.samples)
+ # A weight stream materializes as soon as one packed sample carries it; the
+ # samples that lack it get the stream's identity fill (STREAM_FILL).
+ has_stream = {name: any(getattr(s, name) is not None for _, s in bin_content.samples) for name in STREAM_FILL}
input_ids: list[int] = []
loss_mask: list[bool] = []
@@ -197,8 +237,9 @@ def _materialize_bin(bin_content: _MicroBatchBin, num_loras: int) -> MicroBatch:
temperatures: list[float] = []
env_names: list[str] = []
rewards: list[float] | None = [] if has_rewards else None
- teacher_logprobs: list[float] | None = [] if has_teacher_logprobs else None
+ ref_logprobs: list[float] | None = [] if has_ref_logprobs else None
mm_token_type_ids: list[int] | None = [] if has_mm_token_type_ids else None
+ streams: dict[str, list[float] | None] = {name: ([] if has_stream[name] else None) for name in STREAM_FILL}
routed_experts: RoutedExperts | None = None
lora_num_tokens = [0] * num_loras
@@ -213,10 +254,13 @@ def _materialize_bin(bin_content: _MicroBatchBin, num_loras: int) -> MicroBatch:
env_names.extend(sample.env_names)
if rewards is not None:
rewards.extend(sample.rewards if sample.rewards is not None else [float("nan")] * sample_len)
- if teacher_logprobs is not None:
- teacher_logprobs.extend(
- sample.teacher_logprobs if sample.teacher_logprobs is not None else [0.0] * sample_len
- )
+ if ref_logprobs is not None:
+ ref_logprobs.extend(sample.ref_logprobs if sample.ref_logprobs is not None else [0.0] * sample_len)
+ for name, fill in STREAM_FILL.items():
+ stream = streams[name]
+ if stream is not None:
+ sample_stream = getattr(sample, name)
+ stream.extend(sample_stream if sample_stream is not None else [fill] * sample_len)
if mm_token_type_ids is not None:
mm_token_type_ids.extend(
sample.mm_token_type_ids if sample.mm_token_type_ids is not None else [0] * sample_len
@@ -242,7 +286,7 @@ def _materialize_bin(bin_content: _MicroBatchBin, num_loras: int) -> MicroBatch:
position_ids=position_ids,
inference_logprobs=inference_logprobs,
sequence_lengths=sequence_lengths,
- teacher_logprobs=teacher_logprobs,
+ ref_logprobs=ref_logprobs,
temperatures=temperatures,
rewards=rewards,
lora_num_tokens=lora_num_tokens,
@@ -250,7 +294,9 @@ def _materialize_bin(bin_content: _MicroBatchBin, num_loras: int) -> MicroBatch:
mm_token_type_ids=mm_token_type_ids,
env_names=env_names,
mm_kwargs=first_sample.mm_kwargs if _is_multimodal_sample(first_sample) else None,
- training_mode=first_sample.training_mode,
+ rl_weights=streams["rl_weights"],
+ ce_weights=streams["ce_weights"],
+ ref_kl_weights=streams["ref_kl_weights"],
)
@@ -357,8 +403,15 @@ def pad_micro_batch(micro_batch: MicroBatch, pad_to_multiple_of: int) -> MicroBa
micro_batch.inference_logprobs.extend([0.0] * padding_size)
# Use temperature 1.0 for padding tokens (doesn't matter since loss_mask is False)
micro_batch.temperatures.extend([1.0] * padding_size)
- if micro_batch.teacher_logprobs is not None:
- micro_batch.teacher_logprobs.extend([0.0] * padding_size)
+ if micro_batch.ref_logprobs is not None:
+ micro_batch.ref_logprobs.extend([0.0] * padding_size)
+ # Padding is loss-masked, so no component trains it; fill every stream
+ # with 0.0 (not the pack-boundary defaults) so a padded pure-ce batch
+ # still reads as rl-empty in token export, which keys off nonzero weights.
+ for stream_name in STREAM_FILL:
+ stream = getattr(micro_batch, stream_name)
+ if stream is not None:
+ stream.extend([0.0] * padding_size)
if micro_batch.lora_num_tokens is not None:
micro_batch.lora_num_tokens[-1] += (
padding_size # We send padding to the last lora so that tokens have ascending lora idx
@@ -372,11 +425,49 @@ def pad_micro_batch(micro_batch: MicroBatch, pad_to_multiple_of: int) -> MicroBa
return micro_batch
+def _assert_token_arrays_aligned(micro_batch: MicroBatch) -> None:
+ """Every per-token array must stay position-aligned with ``input_ids``
+ through packing and padding — a field extended without backfill would
+ corrupt training silently."""
+ num_tokens = len(micro_batch.input_ids)
+ per_token_fields = (
+ "loss_mask",
+ "advantages",
+ "inference_logprobs",
+ "position_ids",
+ "temperatures",
+ "env_names",
+ "ref_logprobs",
+ "rl_weights",
+ "ce_weights",
+ "ref_kl_weights",
+ "rewards",
+ "mm_token_type_ids",
+ )
+ for name in per_token_fields:
+ values = getattr(micro_batch, name)
+ assert values is None or len(values) == num_tokens, (
+ f"{name} misaligned after packing: {len(values)} != {num_tokens} tokens"
+ )
+ assert sum(micro_batch.sequence_lengths) == num_tokens, (
+ f"sequence_lengths sum {sum(micro_batch.sequence_lengths)} != {num_tokens} tokens"
+ )
+ if micro_batch.routed_experts is not None:
+ assert micro_batch.routed_experts.shape[0] == num_tokens, (
+ f"routed_experts misaligned after packing: {micro_batch.routed_experts.shape[0]} != {num_tokens} tokens"
+ )
+
+
def _make_dummy_batch(source: MicroBatch) -> MicroBatch:
"""Create a zero-loss dummy batch from an existing batch, preserving its modality."""
dummy = copy.deepcopy(source)
dummy.advantages = [0.0] * len(dummy.input_ids)
dummy.loss_mask = [False] * len(dummy.input_ids)
+ # ce/ref_kl membership is weight != 0 (independent of loss_mask), so the
+ # streams must go too or the dummy would still train those tokens.
+ dummy.rl_weights = None
+ dummy.ce_weights = None
+ dummy.ref_kl_weights = None
return dummy
@@ -411,6 +502,8 @@ def prepare_batch(
micro_batches = packed_samples_into_micro_bs(all_samples, seq_len, num_loras, num_train_workers, bin_cost)
micro_batches = [pad_micro_batch(micro_batch, pad_to_multiple_of) for micro_batch in micro_batches]
+ for micro_batch in micro_batches:
+ _assert_token_arrays_aligned(micro_batch)
# Separate by modality so each step index has uniform modality across all ranks
mm_batches = [b for b in micro_batches if _is_multimodal_sample(b)]
diff --git a/src/prime_rl/trainer/rl/broadcast/filesystem.py b/src/prime_rl/trainer/rl/broadcast/filesystem.py
index e8e2d68db9..49110352f9 100644
--- a/src/prime_rl/trainer/rl/broadcast/filesystem.py
+++ b/src/prime_rl/trainer/rl/broadcast/filesystem.py
@@ -77,7 +77,7 @@ def broadcast_weights(self, model: nn.Module, step: int) -> None:
self.logger.debug(f"Saving weights for run {idx} to {save_dir}")
save_state_dict(state_dict, save_dir, self.save_format, self.save_sharded, adapter=adapter_only)
if adapter_only:
- orch_lora = self.multi_run_manager.config[idx].student.model.lora
+ orch_lora = self.multi_run_manager.config[idx].model.lora
save_lora_config(
model,
save_dir,
diff --git a/src/prime_rl/trainer/rl/data.py b/src/prime_rl/trainer/rl/data.py
index 7c3189bc4d..0aaaa52125 100644
--- a/src/prime_rl/trainer/rl/data.py
+++ b/src/prime_rl/trainer/rl/data.py
@@ -28,7 +28,7 @@ class TensorMicroBatch(TypedDict):
advantages: Float[Tensor, "batch seq"]
rewards: Float[Tensor, "batch seq"] | None
inference_logprobs: Float[Tensor, "batch seq"]
- teacher_logprobs: Float[Tensor, "batch seq"] | None
+ ref_logprobs: Float[Tensor, "batch seq"] | None
loss_mask: Bool[Tensor, "batch seq"]
temperatures: Float[Tensor, "batch seq"] # Per-token temperatures
env_names: list[str]
@@ -49,9 +49,11 @@ class TensorMicroBatch(TypedDict):
# mm_token_type_ids: token type per token [batch seq], int64 (0=text, 1=image, 2=video)
mm_token_type_ids: Int[Tensor, "batch seq"] | None
- # Selects loss dispatch (rl/opd → default loss with mode-specific taus,
- # sft → sft loss). All samples in a micro batch share the same mode.
- training_mode: str
+ # Per-token component weight streams. ``None`` means absent: no ce/ref_kl
+ # component, rl weight 1.0 on every loss-masked token.
+ rl_weights: Float[Tensor, "batch seq"] | None
+ ce_weights: Float[Tensor, "batch seq"] | None
+ ref_kl_weights: Float[Tensor, "batch seq"] | None
# Packer-derived metadata used for run-local debug exports.
run_id: str | None
@@ -124,7 +126,7 @@ def _get_sample_micro_batch(self, generator: torch.Generator) -> TensorMicroBatc
"advantages": advantages.unsqueeze(0),
"rewards": None,
"inference_logprobs": inference_logprobs.unsqueeze(0),
- "teacher_logprobs": None,
+ "ref_logprobs": None,
"temperatures": torch.ones(input_ids.shape[0]).unsqueeze(0),
"env_names": ["fake"] * input_ids.shape[0],
"sequence_lengths": sequence_lengths,
@@ -133,7 +135,9 @@ def _get_sample_micro_batch(self, generator: torch.Generator) -> TensorMicroBatc
"routed_experts": None,
"mm_kwargs": None,
"mm_token_type_ids": None,
- "training_mode": "rl",
+ "rl_weights": None,
+ "ce_weights": None,
+ "ref_kl_weights": None,
"run_id": None,
"run_step": None,
}
@@ -155,7 +159,7 @@ def _get_micro_batch(self, generator: torch.Generator) -> TensorMicroBatch:
"advantages": torch.randn(self.seq_len, generator=generator).unsqueeze(0),
"rewards": None,
"inference_logprobs": torch.randn(self.seq_len, generator=generator).unsqueeze(0),
- "teacher_logprobs": None,
+ "ref_logprobs": None,
"temperatures": torch.ones(self.seq_len).unsqueeze(0),
"env_names": ["fake"] * self.seq_len,
"sequence_lengths": [self.seq_len],
@@ -164,7 +168,9 @@ def _get_micro_batch(self, generator: torch.Generator) -> TensorMicroBatch:
"routed_experts": None,
"mm_kwargs": None,
"mm_token_type_ids": None,
- "training_mode": "rl",
+ "rl_weights": None,
+ "ce_weights": None,
+ "ref_kl_weights": None,
"run_id": None,
"run_step": None,
}
@@ -251,8 +257,8 @@ def _micro_batch_to_tensor(self, micro_batch: MicroBatch) -> TensorMicroBatch:
if micro_batch.rewards is not None
else None,
inference_logprobs=torch.tensor(micro_batch.inference_logprobs, dtype=torch.float).unsqueeze(0),
- teacher_logprobs=torch.tensor(micro_batch.teacher_logprobs, dtype=torch.float).unsqueeze(0)
- if micro_batch.teacher_logprobs is not None
+ ref_logprobs=torch.tensor(micro_batch.ref_logprobs, dtype=torch.float).unsqueeze(0)
+ if micro_batch.ref_logprobs is not None
else None,
loss_mask=torch.tensor(micro_batch.loss_mask, dtype=torch.bool).unsqueeze(0),
temperatures=torch.tensor(micro_batch.temperatures, dtype=torch.float).unsqueeze(0),
@@ -264,7 +270,15 @@ def _micro_batch_to_tensor(self, micro_batch: MicroBatch) -> TensorMicroBatch:
if micro_batch.mm_token_type_ids is not None
else None,
routed_experts=routed_experts,
- training_mode=micro_batch.training_mode,
+ rl_weights=torch.tensor(micro_batch.rl_weights, dtype=torch.float).unsqueeze(0)
+ if micro_batch.rl_weights is not None
+ else None,
+ ce_weights=torch.tensor(micro_batch.ce_weights, dtype=torch.float).unsqueeze(0)
+ if micro_batch.ce_weights is not None
+ else None,
+ ref_kl_weights=torch.tensor(micro_batch.ref_kl_weights, dtype=torch.float).unsqueeze(0)
+ if micro_batch.ref_kl_weights is not None
+ else None,
run_id=micro_batch.run_id,
run_step=micro_batch.run_step,
)
diff --git a/src/prime_rl/trainer/rl/loss.py b/src/prime_rl/trainer/rl/loss.py
index 7cd5e53bb2..3de7dbebe5 100644
--- a/src/prime_rl/trainer/rl/loss.py
+++ b/src/prime_rl/trainer/rl/loss.py
@@ -1,4 +1,4 @@
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Any, Callable
import torch
@@ -12,13 +12,20 @@
@dataclass
class LossInputs:
- """Inputs for computing loss on a single sample."""
+ """Inputs for computing loss on a single sample.
+
+ ``loss_mask`` already selects the tokens that belong to the receiving
+ component — the component loss functions never re-derive eligibility.
+ ``loss_weights`` is the component's per-token weight stream (None means
+ 1.0 everywhere).
+ """
trainer_logprobs: Float[Tensor, " seq"]
inference_logprobs: Float[Tensor, " seq"]
- teacher_logprobs: Float[Tensor, " seq"] | None
+ ref_logprobs: Float[Tensor, " seq"] | None
advantages: Float[Tensor, " seq"]
loss_mask: Bool[Tensor, " seq"]
+ loss_weights: Float[Tensor, " seq"] | None = field(default=None)
@dataclass
@@ -150,7 +157,10 @@ def default_loss_fn(inputs: LossInputs, loss_config: DefaultLossConfig) -> LossO
advantages = loss_config.adv_tau * advantages
pg_loss = keep_mask * advantages * importance_ratio
kl_loss = loss_mask * log_importance_ratio**2
- loss = (-pg_loss + loss_config.kl_tau * kl_loss).sum()
+ per_token_loss = -pg_loss + loss_config.kl_tau * kl_loss
+ if inputs.loss_weights is not None:
+ per_token_loss = per_token_loss * inputs.loss_weights
+ loss = per_token_loss.sum()
metrics = {
"masked_mismatch_kl": _safe_mean(mismatch_kl, loss_mask & is_masked), # all trainable, masked tokens
@@ -166,6 +176,9 @@ def default_loss_fn(inputs: LossInputs, loss_config: DefaultLossConfig) -> LossO
def ipo_loss_fn(inputs: LossInputs, loss_config: IPOLossConfig) -> LossOutputs:
+ """IPO loss type: a symmetric trust region (mask tokens whose probability
+ moved more than ``ipo_threshold`` in absolute terms), policy gradient via
+ the importance ratio, and a squared-log-ratio KL regularizer."""
trainer_logprobs = inputs.trainer_logprobs
inference_logprobs = inputs.inference_logprobs
advantages = inputs.advantages
@@ -183,7 +196,10 @@ def ipo_loss_fn(inputs: LossInputs, loss_config: IPOLossConfig) -> LossOutputs:
advantages = loss_config.adv_tau * advantages
pg_loss = keep_mask * advantages * importance_ratio
kl_loss = loss_mask * log_importance_ratio**2
- loss = (-pg_loss + loss_config.kl_tau * kl_loss).sum()
+ per_token_loss = -pg_loss + loss_config.kl_tau * kl_loss
+ if inputs.loss_weights is not None:
+ per_token_loss = per_token_loss * inputs.loss_weights
+ loss = per_token_loss.sum()
metrics = {
"masked_mismatch_kl": _safe_mean(mismatch_kl, loss_mask & is_masked), # all trainable, masked tokens
@@ -194,86 +210,74 @@ def ipo_loss_fn(inputs: LossInputs, loss_config: IPOLossConfig) -> LossOutputs:
return LossOutputs(loss=loss, metrics=metrics)
-def opd_loss_fn(inputs: LossInputs) -> LossOutputs:
+def ref_kl_loss_fn(inputs: LossInputs) -> LossOutputs:
"""
- On-policy distillation loss: the default DPPO+KL math with the tau knobs
- hardcoded to drop the reward signal and use the teacher KL as the
- per-token policy-gradient signal.
+ Ref-KL loss type (on-policy distillation): the reverse KL to the reference
+ model is the per-token policy-gradient signal, with the importance ratio
+ correcting trainer/inference mismatch and staleness. A one-sided trust
+ region drops tokens whose trainer probability fell more than 0.2 below the
+ inference probability; a squared-log-ratio term regularizes drift. Scalar
+ advantages are not read — ref_kl algorithms ship none.
"""
trainer_logprobs = inputs.trainer_logprobs
inference_logprobs = inputs.inference_logprobs
- teacher_logprobs = inputs.teacher_logprobs
- advantages = inputs.advantages
+ ref_logprobs = inputs.ref_logprobs
loss_mask = inputs.loss_mask
- if teacher_logprobs is None:
- raise ValueError("opd_loss_fn requires teacher_logprobs - configure a teacher for opd mode.")
+ if ref_logprobs is None:
+ raise ValueError("ref_kl loss type requires ref_logprobs — use an 'opd' or 'opsd' advantage strategy.")
log_importance_ratio, importance_ratio, mismatch_kl = compute_importance_ratio_and_mismatch_kl(
trainer_logprobs, inference_logprobs
)
probs_diff = torch.exp(trainer_logprobs) - torch.exp(inference_logprobs)
- dppo_invalid_mask_high = probs_diff > 0.2
- dppo_invalid_mask_low = probs_diff < -0.2
- positive_advantages = advantages > 0
- negative_advantages = advantages < 0
- dppo_invalid_mask = torch.where(positive_advantages, dppo_invalid_mask_high, dppo_invalid_mask_low)
-
- is_masked = dppo_invalid_mask
- is_masked_high = positive_advantages & dppo_invalid_mask_high
- is_masked_low = negative_advantages & dppo_invalid_mask_low
+ is_masked = probs_diff < -0.2
drop_mask = loss_mask & is_masked
keep_mask = loss_mask & ~is_masked
- teacher_kl = teacher_logprobs - trainer_logprobs
- advantages = 0.0 * advantages + 1.0 * teacher_kl.detach()
+ ref_kl = ref_logprobs - trainer_logprobs
- pg_loss = keep_mask * advantages * importance_ratio
+ pg_loss = keep_mask * ref_kl.detach() * importance_ratio
kl_loss = loss_mask * log_importance_ratio**2
- loss = (-pg_loss + 1e-3 * kl_loss).sum()
+ per_token_loss = -pg_loss + 1e-3 * kl_loss
+ if inputs.loss_weights is not None:
+ per_token_loss = per_token_loss * inputs.loss_weights
+ loss = per_token_loss.sum()
+ # Namespaced: the rl loss fn emits same-named trust-region metrics with a
+ # different definition, and mixed batches run both fns in one step.
metrics = {
- "masked_mismatch_kl": _safe_mean(mismatch_kl, loss_mask & is_masked),
- "unmasked_mismatch_kl": _safe_mean(mismatch_kl, keep_mask),
- "is_masked": _safe_mean(is_masked, loss_mask),
- "is_masked_low": _safe_mean(is_masked_low, loss_mask),
- "is_masked_high": _safe_mean(is_masked_high, loss_mask),
- "masked_advantage_positive": _safe_mean(positive_advantages, drop_mask),
- "masked_advantage_negative": _safe_mean(negative_advantages, drop_mask),
- "teacher_kl": _safe_mean(teacher_kl, loss_mask),
+ "ref_kl/masked_mismatch_kl": _safe_mean(mismatch_kl, drop_mask),
+ "ref_kl/unmasked_mismatch_kl": _safe_mean(mismatch_kl, keep_mask),
+ "ref_kl/is_masked": _safe_mean(is_masked, loss_mask),
+ "ref_kl": _safe_mean(ref_kl, loss_mask),
}
return LossOutputs(loss=loss, metrics=metrics)
-def sft_loss_fn(inputs: LossInputs) -> LossOutputs:
- """SFT-style masked negative log-likelihood over trainable tokens."""
+def ce_loss_fn(inputs: LossInputs) -> LossOutputs:
+ """Cross-entropy loss type: masked negative log-likelihood (SFT / ECHO
+ observation prediction)."""
trainer_logprobs = inputs.trainer_logprobs
loss_mask = inputs.loss_mask
- loss = -(trainer_logprobs[loss_mask]).sum()
+ nll = -trainer_logprobs
+ if inputs.loss_weights is not None:
+ nll = nll * inputs.loss_weights
+ loss = nll[loss_mask].sum()
metrics = {
"nll": _safe_mean(-trainer_logprobs, loss_mask),
}
return LossOutputs(loss=loss, metrics=metrics)
-def setup_loss_fns(loss_config: LossConfig) -> dict[str, LossFn]:
- """Build the per-training-mode loss fn dispatch table.
-
- Always returns all three modes - the trainer is mode-agnostic and routes
- per batch from ``TrainingSample.training_mode``:
-
- - ``"sft"`` → ``sft_loss_fn`` (masked NLL on teacher tokens)
- - ``"opd"`` → ``opd_loss_fn`` (teacher KL as gradient signal, hardcoded
- DPPO + KL knobs)
- - ``"rl"`` → ``default_loss_fn(loss_config)`` for ``DefaultLossConfig``,
- ``ipo_loss_fn(loss_config)`` for ``IPOLossConfig``, or the imported
- function for ``CustomLossConfig``.
-
- ``trainer.loss`` only affects the rl path - opd and sft are independent.
- """
+def setup_rl_loss_fn(loss_config: LossConfig) -> LossFn:
+ """Build the loss fn for the rl component from ``trainer.loss``:
+ ``default_loss_fn`` (``DefaultLossConfig``), ``ipo_loss_fn``
+ (``IPOLossConfig``), or the imported function (``CustomLossConfig``).
+ The ce / ref_kl loss types are fixed and unaffected by ``trainer.loss``."""
if isinstance(loss_config, CustomLossConfig):
custom_fn = import_object(loss_config.import_path)
kwargs = loss_config.kwargs
@@ -289,78 +293,119 @@ def rl_fn(inputs: LossInputs) -> LossOutputs:
def rl_fn(inputs: LossInputs) -> LossOutputs:
return default_loss_fn(inputs, loss_config)
- return {"sft": sft_loss_fn, "opd": opd_loss_fn, "rl": rl_fn}
+ return rl_fn
def compute_loss(
trainer_logprobs: list[Float[Tensor, " seq_i"]],
inference_logprobs: list[Float[Tensor, " seq_i"]],
- teacher_logprobs: list[Float[Tensor, " seq_i"]] | None,
+ ref_logprobs: list[Float[Tensor, " seq_i"]] | None,
advantages: list[Float[Tensor, " seq_i"]],
loss_mask: list[Bool[Tensor, " seq_i"]],
- loss_fns: dict[str, LossFn],
- loss_scale: int,
- training_mode: str = "rl",
+ rl_weights: list[Float[Tensor, " seq_i"]] | None,
+ ce_weights: list[Float[Tensor, " seq_i"]] | None,
+ ref_kl_weights: list[Float[Tensor, " seq_i"]] | None,
+ rl_loss_fn: LossFn,
+ rl_scale: int,
+ ce_scale: int,
+ ref_kl_scale: int,
) -> tuple[Float[Tensor, ""], dict[str, Any]]:
"""
Compute loss for packed sequences (batch size = 1, multiple sequences packed along sequence dimension).
- Loss dispatch is batch-driven: ``training_mode`` selects the loss fn from
- ``loss_fns`` (built by ``setup_loss_fns``). sft → sft_loss_fn, opd →
- opd_loss_fn, rl → the configured default/custom loss.
+ The loss is a sum of three components, each running over its own per-token
+ weight stream and normalized by its own global token count:
+
+ - rl → ``rl_loss_fn`` (built by ``setup_rl_loss_fn``) on
+ ``loss_mask & (rl_weights != 0)``; an absent stream means weight 1.0 on
+ the full loss mask (the hot path — no extra device syncs).
+ - ce → ``ce_loss_fn`` (masked NLL) on ``ce_weights != 0``.
+ - ref_kl → ``ref_kl_loss_fn`` on ``ref_kl_weights != 0``.
+
+ A weight scales its component's per-token loss; 0.0 removes the token from
+ the component's mask and denominator. Per-component normalization keeps the
+ components from diluting each other: a token only enters the denominator of
+ the components it belongs to.
Args:
trainer_logprobs: Log probabilities for each sequence
- inference_logprobs: Reference log probabilities for each sequence
- teacher_logprobs: Teacher log probabilities for each sequence, or None
+ inference_logprobs: Sampling-policy log probabilities for each sequence
+ ref_logprobs: Reference-model log probabilities for each sequence, or None
advantages: Advantages for each sequence
loss_mask: Loss mask for each sequence
- loss_fns: Per-mode loss fn dispatch table from setup_loss_fns()
- loss_scale: Scale factor to normalize the loss
- training_mode: Selects which loss fn to apply
+ rl_weights: Per-token rl weights for each sequence, or None (1.0 on the loss mask)
+ ce_weights: Per-token ce weights for each sequence, or None (no ce component)
+ ref_kl_weights: Per-token ref_kl weights for each sequence, or None (no ref_kl component)
+ rl_loss_fn: Loss fn for the rl component from setup_rl_loss_fn()
+ rl_scale: Global rl-token count normalizing the rl component
+ ce_scale: Global ce-token count normalizing the ce component
+ ref_kl_scale: Global ref_kl-token count normalizing the ref_kl component
Returns:
Tuple of (scaled_loss, aggregated_metrics)
"""
- try:
- effective_loss_fn = loss_fns[training_mode]
- except KeyError:
- raise ValueError(
- f"No loss fn available for training_mode={training_mode!r} "
- f"(available: {sorted(loss_fns)}). Check trainer.loss.type."
- )
-
- total_loss = 0.0
all_metrics: dict[str, list[Tensor]] = {}
- if teacher_logprobs is None:
- teacher_logprobs = [None] * len(trainer_logprobs)
-
- for t_logp, i_logp, teach_logp, adv, mask in zip(
+ n = len(trainer_logprobs)
+ if ref_logprobs is None:
+ ref_logprobs = [None] * n
+ if rl_weights is None:
+ rl_weights = [None] * n
+ if ce_weights is None:
+ ce_weights = [None] * n
+ if ref_kl_weights is None:
+ ref_kl_weights = [None] * n
+
+ def run_loss_fn(loss_fn: LossFn, inputs: LossInputs) -> Tensor:
+ result = loss_fn(inputs)
+ for k, v in result.metrics.items():
+ all_metrics.setdefault(k, []).append(v)
+ return result.loss
+
+ # Graph anchor: a micro batch whose components are all empty (e.g. a fully
+ # truncated distillation sample, whose stamped streams survive as all-zero
+ # prefixes) must still return a backward-able loss so every rank runs
+ # backward and FSDP collectives stay in sync.
+ rl_loss = trainer_logprobs[0].sum() * 0.0
+ ce_loss = 0.0
+ ref_kl_loss = 0.0
+ for t_logp, i_logp, ref_logp, adv, mask, rl_w, ce_w, ref_kl_w in zip(
trainer_logprobs,
inference_logprobs,
- teacher_logprobs,
+ ref_logprobs,
advantages,
loss_mask,
+ rl_weights,
+ ce_weights,
+ ref_kl_weights,
):
- inputs = LossInputs(
- trainer_logprobs=t_logp,
- inference_logprobs=i_logp,
- teacher_logprobs=teach_logp,
- advantages=adv,
- loss_mask=mask,
- )
-
- result = effective_loss_fn(inputs)
-
- total_loss = total_loss + result.loss
- for k, v in result.metrics.items():
- if k not in all_metrics:
- all_metrics[k] = []
- all_metrics[k].append(v)
-
- scaled_loss = total_loss / loss_scale
+ def make_inputs(component_mask: Bool[Tensor, " seq"], weights: Float[Tensor, " seq"] | None) -> LossInputs:
+ return LossInputs(
+ trainer_logprobs=t_logp,
+ inference_logprobs=i_logp,
+ ref_logprobs=ref_logp,
+ advantages=adv,
+ loss_mask=component_mask,
+ loss_weights=weights,
+ )
+
+ if rl_w is None:
+ rl_loss = rl_loss + run_loss_fn(rl_loss_fn, make_inputs(mask, None))
+ else:
+ rl_mask = mask & (rl_w != 0)
+ if bool(rl_mask.any()):
+ rl_loss = rl_loss + run_loss_fn(rl_loss_fn, make_inputs(rl_mask, rl_w))
+ if ce_w is not None:
+ ce_mask = ce_w != 0
+ if bool(ce_mask.any()):
+ ce_loss = ce_loss + run_loss_fn(ce_loss_fn, make_inputs(ce_mask, ce_w))
+ if ref_kl_w is not None:
+ ref_kl_mask = ref_kl_w != 0
+ if bool(ref_kl_mask.any()):
+ ref_kl_loss = ref_kl_loss + run_loss_fn(ref_kl_loss_fn, make_inputs(ref_kl_mask, ref_kl_w))
+
+ scaled_loss = rl_loss / rl_scale + ce_loss / ce_scale + ref_kl_loss / ref_kl_scale
aggregated: dict[str, Any] = {}
for k, v in all_metrics.items():
diff --git a/src/prime_rl/trainer/rl/packer.py b/src/prime_rl/trainer/rl/packer.py
index ae0d9863e1..b0daa48db9 100644
--- a/src/prime_rl/trainer/rl/packer.py
+++ b/src/prime_rl/trainer/rl/packer.py
@@ -188,10 +188,10 @@ def _validate_sample(self, sample: TrainingSample) -> tuple[bool, str | None]:
False,
f"Run wrote a sample with length {sample_length} which exceeds max sequence length {self.seq_len}",
)
- if sample.teacher_logprobs is not None and len(sample.teacher_logprobs) != sample_length:
+ if sample.ref_logprobs is not None and len(sample.ref_logprobs) != sample_length:
return (
False,
- f"Run wrote a sample with teacher logprobs length != sample length ({len(sample.teacher_logprobs)} != {sample_length})",
+ f"Run wrote a sample with ref logprobs length != sample length ({len(sample.ref_logprobs)} != {sample_length})",
)
return True, None
diff --git a/src/prime_rl/trainer/rl/token_export.py b/src/prime_rl/trainer/rl/token_export.py
index 29d490302f..efc17e1084 100644
--- a/src/prime_rl/trainer/rl/token_export.py
+++ b/src/prime_rl/trainer/rl/token_export.py
@@ -70,7 +70,6 @@ def export(
"export_sequence_idx": self._sequences_by_file.get(file_key, 0),
"run_id": run_id,
"env_name": _first_non_empty(columns["env_names"][start:end]),
- "training_mode": str(micro_batch["training_mode"]),
**_slice_columns(columns, start, end),
},
run_id,
@@ -163,6 +162,11 @@ def _export_columns(
"is_masked": _optional_tensor_to_bools(export_tensors["is_masked"], seq_len),
"is_masked_high": _optional_tensor_to_bools(export_tensors["is_masked_high"], seq_len),
"is_masked_low": _optional_tensor_to_bools(export_tensors["is_masked_low"], seq_len),
+ # Component weight streams; ``None`` columns mean the defaults (rl 1.0
+ # on the loss mask, no ce/ref_kl component).
+ "rl_weights": _optional_tensor_to_floats(micro_batch.get("rl_weights"), seq_len),
+ "ce_weights": _optional_tensor_to_floats(micro_batch.get("ce_weights"), seq_len),
+ "ref_kl_weights": _optional_tensor_to_floats(micro_batch.get("ref_kl_weights"), seq_len),
"env_names": list(micro_batch["env_names"]),
}
@@ -179,7 +183,14 @@ def _compute_export_tensors(
"is_masked_high": None,
"is_masked_low": None,
}
- if micro_batch["training_mode"] == "sft":
+ # Ratio-based fields are meaningless when no token has sampling logprobs
+ # (e.g. pure CE batches distilling frozen-model tokens): no rl member
+ # (stream present but all-zero) and no ref_kl member.
+ rl_weights = micro_batch.get("rl_weights")
+ ref_kl_weights = micro_batch.get("ref_kl_weights")
+ no_rl = rl_weights is not None and not bool((rl_weights != 0).any())
+ no_ref_kl = ref_kl_weights is None or not bool((ref_kl_weights != 0).any())
+ if no_rl and no_ref_kl:
return fields
inference_logprobs = micro_batch["inference_logprobs"].to(trainer_logprobs.device)
diff --git a/src/prime_rl/trainer/rl/train.py b/src/prime_rl/trainer/rl/train.py
index 3e4aa34a0f..512c2d491d 100644
--- a/src/prime_rl/trainer/rl/train.py
+++ b/src/prime_rl/trainer/rl/train.py
@@ -31,7 +31,7 @@
compute_loss,
compute_importance_ratio_and_mismatch_kl,
selective_log_softmax,
- setup_loss_fns,
+ setup_rl_loss_fn,
shift_tensor_left,
shift_tensor_right,
)
@@ -149,9 +149,9 @@ def train(config: TrainerConfig):
logger.info(f"Initializing tokenizer ({config.tokenizer})")
tokenizer = setup_tokenizer(config.tokenizer)
- # Set up the loss function
+ # Set up the loss function for the RL loss type (ce / ref_kl are fixed)
logger.info(f"Setting up loss function ({config.loss})")
- loss_fns = setup_loss_fns(config.loss)
+ rl_loss_fn = setup_rl_loss_fn(config.loss)
# Set up the optimizer
logger.info(f"Initializing optimizer ({config.optim})")
@@ -351,15 +351,30 @@ def load_run_checkpoint(_optimizer, idx: int) -> None:
forward_backward_start_time = time.perf_counter()
seq_len = micro_batches[0]["input_ids"].shape[1]
- # Normalize by the global (dp_cp) number of unmasked tokens in the batch, so every rank
- # divides by the same denominator. With a per-rank denominator, ranks with fewer loss
- # tokens implicitly upweight their per-token gradient contribution after FSDP averaging.
- # FSDP's per-rank divide is undone after the microbatch loop via fsdp_gradient_divide_factor.
- local_loss_scale = sum(micro_batch["loss_mask"].sum().item() for micro_batch in micro_batches)
- global_loss_scale = torch.tensor(local_loss_scale, dtype=torch.int64, device="cuda")
+ # Normalize each loss component by its own global (dp_cp) token count, so every rank
+ # divides by the same denominator and the components don't dilute each other — a token
+ # only enters the denominator of the components it belongs to. With a per-rank
+ # denominator, ranks with fewer loss tokens implicitly upweight their per-token gradient
+ # contribution after FSDP averaging. FSDP's per-rank divide is undone after the
+ # microbatch loop via fsdp_gradient_divide_factor. One batched collective keeps every
+ # rank issuing the same op regardless of which components its samples carry.
+ local_rl_scale = 0
+ local_ce_scale = 0
+ local_ref_kl_scale = 0
+ for micro_batch in micro_batches:
+ mask = micro_batch["loss_mask"]
+ rl_w = micro_batch["rl_weights"]
+ local_rl_scale += int((mask & (rl_w != 0)).sum()) if rl_w is not None else int(mask.sum())
+ if micro_batch["ce_weights"] is not None:
+ local_ce_scale += int((micro_batch["ce_weights"] != 0).sum())
+ if micro_batch["ref_kl_weights"] is not None:
+ local_ref_kl_scale += int((micro_batch["ref_kl_weights"] != 0).sum())
+ global_scales = torch.tensor(
+ [local_rl_scale, local_ce_scale, local_ref_kl_scale], dtype=torch.int64, device="cuda"
+ )
dp_cp_group = parallel_dims.get_mesh("dp_cp").get_group()
- dist.all_reduce(global_loss_scale, op=dist.ReduceOp.SUM, group=dp_cp_group)
- loss_scale = max(global_loss_scale.item(), 1)
+ dist.all_reduce(global_scales, op=dist.ReduceOp.SUM, group=dp_cp_group)
+ rl_scale, ce_scale, ref_kl_scale = (max(scale, 1) for scale in global_scales.tolist())
logger.debug(f"Starting forward and backward pass ({batch_size=})")
tensors = Tensors() # Used to accumulate tensor statistics across micro-batches and ranks for logging
@@ -374,8 +389,11 @@ def load_run_checkpoint(_optimizer, idx: int) -> None:
advantages = micro_batch["advantages"].to("cuda")
loss_mask = micro_batch["loss_mask"].to("cuda")
inference_logprobs = micro_batch["inference_logprobs"].to("cuda")
- teacher_logprobs = (
- micro_batch["teacher_logprobs"].to("cuda") if micro_batch["teacher_logprobs"] is not None else None
+ ref_logprobs = micro_batch["ref_logprobs"].to("cuda") if micro_batch["ref_logprobs"] is not None else None
+ rl_weights = micro_batch["rl_weights"].to("cuda") if micro_batch["rl_weights"] is not None else None
+ ce_weights = micro_batch["ce_weights"].to("cuda") if micro_batch["ce_weights"] is not None else None
+ ref_kl_weights = (
+ micro_batch["ref_kl_weights"].to("cuda") if micro_batch["ref_kl_weights"] is not None else None
)
routed_experts = (
micro_batch["routed_experts"].to("cuda") if micro_batch["routed_experts"] is not None else None
@@ -477,14 +495,16 @@ def load_run_checkpoint(_optimizer, idx: int) -> None:
loss, loss_tensors = compute_loss(
trainer_logprobs=out["logprobs"].squeeze().split(sequence_lengths),
inference_logprobs=inference_logprobs.squeeze().split(sequence_lengths),
- teacher_logprobs=teacher_logprobs.squeeze().split(sequence_lengths)
- if teacher_logprobs is not None
- else None,
+ ref_logprobs=ref_logprobs.squeeze().split(sequence_lengths) if ref_logprobs is not None else None,
advantages=advantages.squeeze().split(sequence_lengths),
loss_mask=loss_mask.squeeze().split(sequence_lengths),
- loss_fns=loss_fns,
- loss_scale=loss_scale,
- training_mode=micro_batch["training_mode"],
+ rl_weights=rl_weights.squeeze().split(sequence_lengths) if rl_weights is not None else None,
+ ce_weights=ce_weights.squeeze().split(sequence_lengths) if ce_weights is not None else None,
+ ref_kl_weights=ref_kl_weights.squeeze().split(sequence_lengths) if ref_kl_weights is not None else None,
+ rl_loss_fn=rl_loss_fn,
+ rl_scale=rl_scale,
+ ce_scale=ce_scale,
+ ref_kl_scale=ref_kl_scale,
)
# Backward pass
@@ -505,12 +525,30 @@ def load_run_checkpoint(_optimizer, idx: int) -> None:
for env_name, indices in env_to_indices.items():
tensors[f"entropy/{env_name}"].append(entropy[indices])
- if micro_batch["training_mode"] != "sft":
+ # Mismatch KL is only meaningful where sampling logprobs exist —
+ # keep rl/ref_kl member tokens (policy-sampled), exclude tokens
+ # whose action component is ce (frozen-model tokens).
+ if rl_weights is None and ref_kl_weights is None:
+ mismatch_mask = loss_mask
+ has_mismatch_tokens = True
+ else:
+ sampled_mask = (rl_weights != 0) if rl_weights is not None else loss_mask
+ if ref_kl_weights is not None:
+ sampled_mask = sampled_mask | (ref_kl_weights != 0)
+ mismatch_mask = loss_mask & sampled_mask
+ has_mismatch_tokens = bool(mismatch_mask.any())
+ if has_mismatch_tokens:
with torch.no_grad():
_, _, mismatch_kl = compute_importance_ratio_and_mismatch_kl(out["logprobs"], inference_logprobs)
- mismatch_kl = mismatch_kl[loss_mask].detach().to("cpu")
+ mismatch_kl = mismatch_kl[mismatch_mask].detach().to("cpu")
tensors["mismatch_kl/all"].append(mismatch_kl)
- for env_name, indices in env_to_indices.items():
+ mismatch_env_names = [
+ env_name for env_name, keep in zip(env_names, mismatch_mask.flatten().tolist()) if keep
+ ]
+ mismatch_env_to_indices: dict[str, list[int]] = {}
+ for idx, env_name in enumerate(mismatch_env_names):
+ mismatch_env_to_indices.setdefault(env_name, []).append(idx)
+ for env_name, indices in mismatch_env_to_indices.items():
tensors[f"mismatch_kl/{env_name}"].append(mismatch_kl[indices])
token_exporter.export(
@@ -534,7 +572,7 @@ def load_run_checkpoint(_optimizer, idx: int) -> None:
# Debug log with *local, micro step* stats
micro_step_message = f"Micro Step {micro_step}/{len(micro_batches)} | Loss {tensors['loss'][-1].mean().item():.4f} | Entropy {tensors['entropy/all'][-1].mean().item():.4f}"
- if micro_batch["training_mode"] != "sft":
+ if has_mismatch_tokens:
micro_step_message += f" | Mismatch KL {tensors['mismatch_kl/all'][-1].mean().item():.4f}"
if "max_vio" in tensors:
micro_step_message += f" | Max Vio {tensors['max_vio'][-1].mean().item():.4f}"
diff --git a/src/prime_rl/trainer/runs.py b/src/prime_rl/trainer/runs.py
index 1bdb7e0f50..bc9dd9cb08 100644
--- a/src/prime_rl/trainer/runs.py
+++ b/src/prime_rl/trainer/runs.py
@@ -515,24 +515,22 @@ def setup_multi_run_manager(
trainer_lora = lora_config
def validate_lora_rank(orch_config: "OrchestratorConfig") -> tuple[bool, str]:
- if orch_config.student.model.lora is None:
- return False, "student.model.lora is required when trainer is configured with LoRA"
+ if orch_config.model.lora is None:
+ return False, "orchestrator.model.lora is required when trainer is configured with LoRA"
# Default to trainer's rank/alpha if not specified
- if orch_config.student.model.lora.rank is None:
- orch_config.student.model.lora.rank = trainer_lora.rank
- if orch_config.student.model.lora.alpha is None:
- orch_config.student.model.lora.alpha = trainer_lora.alpha
- if orch_config.student.model.lora.rank > trainer_lora.rank:
+ if orch_config.model.lora.rank is None:
+ orch_config.model.lora.rank = trainer_lora.rank
+ if orch_config.model.lora.alpha is None:
+ orch_config.model.lora.alpha = trainer_lora.alpha
+ if orch_config.model.lora.rank > trainer_lora.rank:
return (
False,
- f"student.model.lora.rank ({orch_config.student.model.lora.rank}) exceeds trainer max rank ({trainer_lora.rank})",
+ f"orchestrator.model.lora.rank ({orch_config.model.lora.rank}) exceeds trainer max rank ({trainer_lora.rank})",
)
return True, ""
def on_run_discovered(idx: int, run_id: str, orch_config: "OrchestratorConfig") -> None:
- _MULTI_RUN_MANAGER.scaling_factors[idx] = (
- orch_config.student.model.lora.alpha / orch_config.student.model.lora.rank
- )
+ _MULTI_RUN_MANAGER.scaling_factors[idx] = orch_config.model.lora.alpha / orch_config.model.lora.rank
_MULTI_RUN_MANAGER.register_config_validation_hook(validate_lora_rank)
_MULTI_RUN_MANAGER.register_discovered_hook(on_run_discovered)
diff --git a/src/prime_rl/trainer/utils.py b/src/prime_rl/trainer/utils.py
index 2d278a1225..bc0f2e8c93 100644
--- a/src/prime_rl/trainer/utils.py
+++ b/src/prime_rl/trainer/utils.py
@@ -606,6 +606,9 @@ def filter_rl_trainer_tensor_stats_for_wandb(metrics: dict[str, float | int]) ->
"mismatch_kl/",
"masked_mismatch_kl/",
"unmasked_mismatch_kl/",
+ "ref_kl/is_masked/",
+ "ref_kl/masked_mismatch_kl/",
+ "ref_kl/unmasked_mismatch_kl/",
)
out: dict[str, float | int] = {}
for k, v in metrics.items():
diff --git a/src/prime_rl/transport/types.py b/src/prime_rl/transport/types.py
index 1666c51092..afacb13cf1 100644
--- a/src/prime_rl/transport/types.py
+++ b/src/prime_rl/transport/types.py
@@ -1,9 +1,5 @@
-from typing import Literal
-
import msgspec
-TrainingMode = Literal["rl", "opd", "sft"]
-
# Encoded tensor: {dtype: "float32", shape: [...], data: }.
# Mirrors verifiers.utils.serve_utils.msgpack_encoder so the same wire
@@ -33,8 +29,7 @@ class TrainingSample(msgspec.Struct, array_like=True, gc=False, omit_defaults=Tr
completion_logprobs: list[float]
completion_temperatures: list[float] # Per-token temperatures used during generation
env_name: str
- teacher_logprobs: list[float] | None = None
- advantage: float | None = None
+ ref_logprobs: list[float] | None = None # reference-model logprobs (ref_kl component)
reward: float | None = None
# Generic multimodal kwargs: flat dict keyed by the kwarg names the
@@ -52,9 +47,33 @@ class TrainingSample(msgspec.Struct, array_like=True, gc=False, omit_defaults=Tr
# mm_token_type_ids: token type ids per token [batch seq], int64 (0=text, 1=image, 2=video)
mm_token_type_ids: list[int] | None = None
- # Loss dispatch is batch-driven: rl/opd use default_loss_fn (with mode-specific
- # taus), sft uses sft_loss_fn. Stamped by the orchestrator from training_mode.
- training_mode: TrainingMode = "rl"
+ # Per-token component weight streams (full prompt+completion length),
+ # stamped by the orchestrator from the env's algorithm. The training loss
+ # is a sum of three components, each normalized by its own global token
+ # count: rl (importance-weighted PG + KL), ce (masked NLL), and ref_kl
+ # (reverse KL to a reference model as the PG signal). A weight scales that
+ # component's per-token loss; 0.0 leaves the token out of the component
+ # (mask and denominator). ``None`` means absent: no ce/ref_kl component,
+ # and an rl weight of 1.0 on every trainable token — so the plain GRPO
+ # wire stays as small as before.
+ rl_weights: list[float] | None = None
+ ce_weights: list[float] | None = None
+ ref_kl_weights: list[float] | None = None
+
+ # Per-token advantages (full prompt+completion length), the fourth stream:
+ # the orchestrator broadcasts the rollout's scalar over the completion for
+ # scalar algorithms. ``None`` means no rl credit assigned — legal only for
+ # samples without live rl member tokens (the trainer raises otherwise).
+ advantages: list[float] | None = None
+
+ # Orchestrator-internal, cleared before transport: interleaving's
+ # provenance record for env-provided observation tokens — one
+ # ``[completion_start, step_idx, step_prompt_start, length]`` entry per
+ # span that landed as a later-turn prompt extension, mapping sample
+ # positions back to trajectory-step coordinates. Algorithms that train
+ # on observations (echo) consume it at group time and write the
+ # ``ce_weights`` stream directly.
+ obs_spans: list[list[int]] | None = None
class TrainingBatch(msgspec.Struct, array_like=True, gc=False, omit_defaults=True):
@@ -76,8 +95,10 @@ class MicroBatch(msgspec.Struct, array_like=True, gc=False, omit_defaults=True):
position_ids: list[int]
temperatures: list[float] # Per-token temperatures used during generation
env_names: list[str]
+ # Per-sample token counts within the packed batch (one entry per packed
+ # sample); the loss splits the packed sequence back into samples by these.
sequence_lengths: list[int]
- teacher_logprobs: list[float] | None = None
+ ref_logprobs: list[float] | None = None
lora_num_tokens: list[int] | None = None
routed_experts: RoutedExperts | None = None
@@ -86,9 +107,12 @@ class MicroBatch(msgspec.Struct, array_like=True, gc=False, omit_defaults=True):
# mm_token_type_ids: token type ids per token [batch seq], int64 (0=text, 1=image, 2=video)
mm_token_type_ids: list[int] | None = None
- # Loss dispatch is batch-driven (rl/opd → default loss with mode-specific taus,
- # sft → sft loss). All samples packed into a micro batch share the same mode.
- training_mode: TrainingMode = "rl"
+ # Per-token component weight streams (see TrainingSample). ``None`` means
+ # absent: no ce/ref_kl component, rl weight 1.0 everywhere — packing
+ # materializes a stream as soon as one packed sample carries it.
+ rl_weights: list[float] | None = None
+ ce_weights: list[float] | None = None
+ ref_kl_weights: list[float] | None = None
rewards: list[float] | None = None
# Packer-derived metadata used for run-local token exports.
diff --git a/tests/integration/test_reverse_text_rl_opd.py b/tests/integration/test_reverse_text_rl_opd.py
index 2ca1d7fdfe..69266b48e7 100644
--- a/tests/integration/test_reverse_text_rl_opd.py
+++ b/tests/integration/test_reverse_text_rl_opd.py
@@ -10,17 +10,17 @@
from prime_rl.utils.process import cleanup_process
from tests.conftest import ProcessResult
-from tests.utils import check_eval_avg_goes_up, check_no_error, strip_escape_codes
+from tests.utils import check_final_eval_reward_above, check_no_error, strip_escape_codes
pytestmark = [pytest.mark.gpu, pytest.mark.slow]
TIMEOUT = 600 # 10 minutes
-TEACHER_PORT = 8001
-TEACHER_READY_TIMEOUT_S = 300
+REF_PORT = 8001
+REF_READY_TIMEOUT_S = 300
-def _wait_for_teacher(port: int, timeout_s: int) -> None:
- """Block until the teacher inference server's /v1/models endpoint is reachable."""
+def _wait_for_ref_server(port: int, timeout_s: int) -> None:
+ """Block until the frozen reference server's /v1/models endpoint is reachable."""
url = f"http://localhost:{port}/v1/models"
deadline = time.time() + timeout_s
while time.time() < deadline:
@@ -35,15 +35,15 @@ def _wait_for_teacher(port: int, timeout_s: int) -> None:
@pytest.fixture(scope="module")
-def teacher_inference(output_dir: Path) -> Generator[subprocess.Popen, None, None]:
- """Spawn a `uv run inference` teacher on GPU 0 (shared with the rl-launched
- student) at 40% gpu_memory_utilization. Tears down at module scope.
+def ref_inference(output_dir: Path) -> Generator[subprocess.Popen, None, None]:
+ """Spawn a `uv run inference` frozen reference server on GPU 0 (shared with the rl-launched
+ policy) at 40% gpu_memory_utilization. Tears down at module scope.
"""
# The rl entrypoint's --clean-output-dir wipes the rl output_dir on start,
- # so park the teacher log next to it instead of inside it.
- teacher_log_dir = output_dir.parent / f"{output_dir.name}_teacher"
- teacher_log_dir.mkdir(parents=True, exist_ok=True)
- log_path = teacher_log_dir / "teacher_inference.log"
+ # so park the reference-server log next to it instead of inside it.
+ ref_log_dir = output_dir.parent / f"{output_dir.name}_ref"
+ ref_log_dir.mkdir(parents=True, exist_ok=True)
+ log_path = ref_log_dir / "ref_inference.log"
cmd = [
"uv",
"run",
@@ -51,7 +51,7 @@ def teacher_inference(output_dir: Path) -> Generator[subprocess.Popen, None, Non
"--model.name",
"PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL",
"--server.port",
- str(TEACHER_PORT),
+ str(REF_PORT),
"--gpu-memory-utilization",
"0.4",
]
@@ -59,7 +59,7 @@ def teacher_inference(output_dir: Path) -> Generator[subprocess.Popen, None, Non
with open(log_path, "w") as log_file:
proc = subprocess.Popen(cmd, env=env, stdout=log_file, stderr=log_file)
try:
- _wait_for_teacher(TEACHER_PORT, TEACHER_READY_TIMEOUT_S)
+ _wait_for_ref_server(REF_PORT, REF_READY_TIMEOUT_S)
yield proc
finally:
cleanup_process(proc.pid, signal.SIGTERM)
@@ -77,13 +77,13 @@ def wandb_name(branch_name: str) -> str:
@pytest.fixture(scope="module")
def rl_opd_process(
- teacher_inference,
+ ref_inference,
run_process: Callable[..., ProcessResult],
output_dir: Path,
wandb_project: str,
wandb_name: str,
) -> ProcessResult:
- """Run the RL entrypoint with training_mode = "opd"; teacher_inference is
+ """Run the RL entrypoint with the opd algorithm; ref_inference is
a fixture-managed external vLLM at http://localhost:8001/v1."""
cmd = [
"uv",
@@ -107,7 +107,7 @@ def test_no_error(rl_opd_process: ProcessResult, output_dir: Path):
check_no_error(rl_opd_process, output_dir)
-def test_eval_avg_goes_up(rl_opd_process: ProcessResult, test_no_error, output_dir: Path):
+def test_eval_reward_converges(rl_opd_process: ProcessResult, test_no_error, output_dir: Path):
with open(output_dir / "logs" / "orchestrator.log", "r") as f:
orchestrator_stdout = strip_escape_codes(f.read()).splitlines()
- check_eval_avg_goes_up(orchestrator_stdout, env_name="reverse-text")
+ check_final_eval_reward_above(orchestrator_stdout, env_name="reverse-text", min_threshold=0.5)
diff --git a/tests/integration/test_reverse_text_rl_sft.py b/tests/integration/test_reverse_text_rl_sft.py
index df8b3f4786..f56a0d683e 100644
--- a/tests/integration/test_reverse_text_rl_sft.py
+++ b/tests/integration/test_reverse_text_rl_sft.py
@@ -10,17 +10,17 @@
from prime_rl.utils.process import cleanup_process
from tests.conftest import ProcessResult
-from tests.utils import check_eval_avg_goes_up, check_no_error, strip_escape_codes
+from tests.utils import check_final_eval_reward_above, check_no_error, strip_escape_codes
pytestmark = [pytest.mark.gpu, pytest.mark.slow]
TIMEOUT = 600 # 10 minutes
-TEACHER_PORT = 8001
-TEACHER_READY_TIMEOUT_S = 300
+REF_PORT = 8001
+REF_READY_TIMEOUT_S = 300
-def _wait_for_teacher(port: int, timeout_s: int) -> None:
- """Block until the teacher inference server's /v1/models endpoint is reachable."""
+def _wait_for_ref_server(port: int, timeout_s: int) -> None:
+ """Block until the frozen reference server's /v1/models endpoint is reachable."""
url = f"http://localhost:{port}/v1/models"
deadline = time.time() + timeout_s
while time.time() < deadline:
@@ -35,15 +35,15 @@ def _wait_for_teacher(port: int, timeout_s: int) -> None:
@pytest.fixture(scope="module")
-def teacher_inference(output_dir: Path) -> Generator[subprocess.Popen, None, None]:
- """Spawn a `uv run inference` teacher on GPU 0 (shared with the rl-launched
- student) at 40% gpu_memory_utilization. Tears down at module scope.
+def ref_inference(output_dir: Path) -> Generator[subprocess.Popen, None, None]:
+ """Spawn a `uv run inference` frozen reference server on GPU 0 (shared with the rl-launched
+ policy) at 40% gpu_memory_utilization. Tears down at module scope.
"""
# The rl entrypoint's --clean-output-dir wipes the rl output_dir on start,
- # so park the teacher log next to it instead of inside it.
- teacher_log_dir = output_dir.parent / f"{output_dir.name}_teacher"
- teacher_log_dir.mkdir(parents=True, exist_ok=True)
- log_path = teacher_log_dir / "teacher_inference.log"
+ # so park the reference-server log next to it instead of inside it.
+ ref_log_dir = output_dir.parent / f"{output_dir.name}_ref"
+ ref_log_dir.mkdir(parents=True, exist_ok=True)
+ log_path = ref_log_dir / "ref_inference.log"
cmd = [
"uv",
"run",
@@ -51,7 +51,7 @@ def teacher_inference(output_dir: Path) -> Generator[subprocess.Popen, None, Non
"--model.name",
"PrimeIntellect/Qwen3-0.6B-Reverse-Text-RL",
"--server.port",
- str(TEACHER_PORT),
+ str(REF_PORT),
"--gpu-memory-utilization",
"0.4",
]
@@ -59,7 +59,7 @@ def teacher_inference(output_dir: Path) -> Generator[subprocess.Popen, None, Non
with open(log_path, "w") as log_file:
proc = subprocess.Popen(cmd, env=env, stdout=log_file, stderr=log_file)
try:
- _wait_for_teacher(TEACHER_PORT, TEACHER_READY_TIMEOUT_S)
+ _wait_for_ref_server(REF_PORT, REF_READY_TIMEOUT_S)
yield proc
finally:
cleanup_process(proc.pid, signal.SIGTERM)
@@ -77,13 +77,13 @@ def wandb_name(branch_name: str) -> str:
@pytest.fixture(scope="module")
def rl_sft_process(
- teacher_inference,
+ ref_inference,
run_process: Callable[..., ProcessResult],
output_dir: Path,
wandb_project: str,
wandb_name: str,
) -> ProcessResult:
- """Run the RL entrypoint with training_mode = "sft"; teacher_inference is
+ """Run the RL entrypoint with the sft algorithm; ref_inference is
a fixture-managed external vLLM at http://localhost:8001/v1."""
cmd = [
"uv",
@@ -107,7 +107,7 @@ def test_no_error(rl_sft_process: ProcessResult, output_dir: Path):
check_no_error(rl_sft_process, output_dir)
-def test_eval_avg_goes_up(rl_sft_process: ProcessResult, test_no_error, output_dir: Path):
+def test_eval_reward_converges(rl_sft_process: ProcessResult, test_no_error, output_dir: Path):
with open(output_dir / "logs" / "orchestrator.log", "r") as f:
orchestrator_stdout = strip_escape_codes(f.read()).splitlines()
- check_eval_avg_goes_up(orchestrator_stdout, env_name="reverse-text")
+ check_final_eval_reward_above(orchestrator_stdout, env_name="reverse-text", min_threshold=0.5)
diff --git a/tests/unit/orchestrator/test_advantage.py b/tests/unit/orchestrator/test_advantage.py
index 89022c8428..5b8c06a50c 100644
--- a/tests/unit/orchestrator/test_advantage.py
+++ b/tests/unit/orchestrator/test_advantage.py
@@ -1,22 +1,25 @@
+import asyncio
import math
import uuid
import pytest
-from prime_rl.configs.orchestrator import (
- CustomAdvantageConfig,
- DefaultAdvantageConfig,
+from prime_rl.configs.algorithm import (
+ AlgorithmConfig,
+ LinearLengthPenaltyConfig,
TokensLengthPenaltyConfig,
TurnsLengthPenaltyConfig,
)
-from prime_rl.orchestrator.advantage import (
- AdvantageInputs,
- AdvantageOutputs,
- assign_advantages,
- default_advantage_fn,
- setup_advantage_fn,
+from prime_rl.orchestrator.algo import CustomAlgorithm, build_algorithm
+from prime_rl.orchestrator.algo.advantage import (
+ apply_advantage_fn,
+ efficiency_shaping_advantage,
+ grpo_advantage,
+ length_penalty_advantage,
+ max_rl_advantage_fn,
)
-from prime_rl.orchestrator.types import TrainRollout
+from prime_rl.orchestrator.types import RolloutView, TrainRollout
+from prime_rl.transport.types import TrainingSample
def _make_rollout(
@@ -28,7 +31,9 @@ def _make_rollout(
) -> dict:
"""Create a minimal rollout dict for advantage testing.
- `completion_len` tokens are split across `num_turns` trajectory steps.
+ `completion_len` tokens are split across `num_turns` trajectory steps —
+ they feed the length penalty's cost computation (read from `raw`), not the
+ sample's advantage length.
"""
per_turn, rem = divmod(completion_len, max(num_turns, 1))
trajectory = [
@@ -43,14 +48,45 @@ def _make_rollout(
}
-def _make_group(rewards, completion_lengths=None, num_turns=None) -> AdvantageInputs:
- """Build single-group AdvantageInputs from 1D arrays of rewards/lengths/turns."""
- rollouts = []
+def _train_rollout(raw: dict, completion_ids: tuple[int, ...] = (2,)) -> TrainRollout:
+ """One ``TrainRollout`` carrying a single training sample with the given
+ completion tokens (default length 1, so a group-norm scalar is its
+ rollout's whole advantage)."""
+ return TrainRollout(
+ raw=raw,
+ env_name="test",
+ example_id=0,
+ group_id=uuid.uuid4(),
+ policy_version=0,
+ off_policy_steps=0,
+ samples=[
+ TrainingSample(
+ prompt_ids=[1],
+ prompt_mask=[False],
+ completion_ids=list(completion_ids),
+ completion_mask=[True] * len(completion_ids),
+ completion_logprobs=[-0.1] * len(completion_ids),
+ completion_temperatures=[],
+ env_name="test",
+ )
+ ],
+ )
+
+
+def _views(raw_rollouts: list[dict]) -> list[RolloutView]:
+ """Wrap raw rollout dicts into ``RolloutView``\\ s over single-token
+ samples — the advantage fns see exactly what ``score_group`` sees."""
+ return [RolloutView(_train_rollout(raw)) for raw in raw_rollouts]
+
+
+def _make_group(rewards, completion_lengths=None, num_turns=None) -> list[RolloutView]:
+ """Build one group of ``RolloutView``\\ s from 1D arrays of rewards/lengths/turns."""
+ raw_rollouts = []
for i, reward in enumerate(rewards):
cl = int(completion_lengths[i]) if completion_lengths is not None else 0
nt = int(num_turns[i]) if num_turns is not None else 1
- rollouts.append(_make_rollout(float(reward), cl, nt))
- return AdvantageInputs(rollouts=rollouts)
+ raw_rollouts.append(_make_rollout(float(reward), cl, nt))
+ return _views(raw_rollouts)
# Helper aliases for readability — completion-only and tool-only token shaping.
@@ -58,39 +94,51 @@ def _make_group(rewards, completion_lengths=None, num_turns=None) -> AdvantageIn
_TOKENS_TOOL_ONLY = TokensLengthPenaltyConfig(completion_weight=0.0, tool_response_weight=1.0)
-def test_default_advantage_fn_simple_mean():
- inputs = _make_group(rewards=[1.0, 0.5, 0.8], completion_lengths=[10, 12, 8])
- result = default_advantage_fn(inputs)
+def test_grpo_advantage_simple_mean():
+ result = grpo_advantage(_make_group(rewards=[1.0, 0.5, 0.8], completion_lengths=[10, 12, 8]))
+
+ assert len(result) == 3
+ assert sum(result) == pytest.approx(0.0, abs=1e-6)
+
+
+def test_max_rl_advantage_fn_mean_normalized():
+ # mean 0.25: the success gets (1 - 0.25)/0.25 = 3, failures (0 - 0.25)/0.25 = -1
+ result = max_rl_advantage_fn(_make_group(rewards=[1.0, 0.0, 0.0, 0.0]))
+ assert result == pytest.approx([3.0, -1.0, -1.0, -1.0])
- assert len(result.advantages) == 3
- assert sum(result.advantages) == pytest.approx(0.0, abs=1e-6)
+ # no-success groups carry no signal (the paper's K=0 convention) ...
+ assert max_rl_advantage_fn(_make_group(rewards=[0.0, 0.0])) == [0.0, 0.0]
+ # ... and all-success groups center to zero like GRPO
+ assert max_rl_advantage_fn(_make_group(rewards=[1.0, 1.0])) == pytest.approx([0.0, 0.0])
def test_efficiency_mixed_group():
"""Mixed group: reward shaping preserves zero-mean, shorter correct gets higher advantage."""
- inputs = _make_group(rewards=[1.0, 1.0, 0.0, 1.0], completion_lengths=[10, 30, 20, 20])
- result = default_advantage_fn(inputs, length_penalty=_TOKENS_COMPLETION)
+ group = _make_group(rewards=[1.0, 1.0, 0.0, 1.0], completion_lengths=[10, 30, 20, 20])
+ result = efficiency_shaping_advantage(group, _TOKENS_COMPLETION)
# mean_correct_len = (10+30+20)/3 = 20
# bonus = clamp(1 - [10,30,20,20]/20, 0, 1) = [0.5, 0, 0, 0]
# shaped_rewards = R * (1 + bonus * correct_mask) = [1.5, 1, 0, 1]
# baseline = mean(shaped_rewards) = 0.875
# A = shaped_rewards - baseline = [0.625, 0.125, -0.875, 0.125]
- assert result.advantages == pytest.approx([0.625, 0.125, -0.875, 0.125], abs=1e-6)
+ assert result == pytest.approx([0.625, 0.125, -0.875, 0.125], abs=1e-6)
# Zero-mean per group
- assert sum(result.advantages) == pytest.approx(0.0, abs=1e-6)
+ assert sum(result) == pytest.approx(0.0, abs=1e-6)
# All correct rollouts have positive advantage
- for rollout, adv in zip(inputs.rollouts, result.advantages):
- if rollout["reward"] >= 1.0:
+ for view, adv in zip(group, result):
+ if view.reward >= 1.0:
assert adv > 0
def test_efficiency_all_correct_group():
"""All-correct group: zero-mean, shorter gets higher advantage."""
- inputs = _make_group(rewards=[1.0, 1.0, 1.0], completion_lengths=[10, 20, 40])
- result = default_advantage_fn(inputs, length_penalty=_TOKENS_COMPLETION)
+ result = efficiency_shaping_advantage(
+ _make_group(rewards=[1.0, 1.0, 1.0], completion_lengths=[10, 20, 40]),
+ _TOKENS_COMPLETION,
+ )
# mean_len = 70/3 ≈ 23.33
# bonus = clamp(1 - [10, 20, 40] / (70/3), 0, 1) = [4/7, 1/7, 0]
@@ -98,67 +146,70 @@ def test_efficiency_all_correct_group():
shaped = [11.0 / 7, 8.0 / 7, 1.0]
mean_shaped = sum(shaped) / len(shaped)
expected = [s - mean_shaped for s in shaped]
- assert result.advantages == pytest.approx(expected, abs=1e-6)
+ assert result == pytest.approx(expected, abs=1e-6)
# Zero-mean
- assert sum(result.advantages) == pytest.approx(0.0, abs=1e-6)
+ assert sum(result) == pytest.approx(0.0, abs=1e-6)
# Shortest has highest advantage
- assert result.advantages[0] > result.advantages[1] > result.advantages[2]
+ assert result[0] > result[1] > result[2]
def test_efficiency_all_zero_rewards():
"""When all rewards are 0, no length shaping — falls back to standard GRPO."""
- inputs = _make_group(rewards=[0.0, 0.0, 0.0], completion_lengths=[10, 20, 15])
- result_with = default_advantage_fn(inputs, length_penalty=_TOKENS_COMPLETION)
- result_without = default_advantage_fn(inputs)
+ group = _make_group(rewards=[0.0, 0.0, 0.0], completion_lengths=[10, 20, 15])
+ result_with = efficiency_shaping_advantage(group, _TOKENS_COMPLETION)
+ result_without = grpo_advantage(group)
- assert result_with.advantages == pytest.approx(result_without.advantages, abs=1e-6)
+ assert result_with == pytest.approx(result_without, abs=1e-6)
def test_efficiency_single_correct():
"""Single correct rollout: bonus=0 (at its own mean), same as standard GRPO."""
- inputs = _make_group(rewards=[1.0, 0.0, 0.0, 0.0], completion_lengths=[100, 50, 200, 150])
- result = default_advantage_fn(inputs, length_penalty=_TOKENS_COMPLETION)
+ result = efficiency_shaping_advantage(
+ _make_group(rewards=[1.0, 0.0, 0.0, 0.0], completion_lengths=[100, 50, 200, 150]),
+ _TOKENS_COMPLETION,
+ )
- assert result.advantages == pytest.approx([0.75, -0.25, -0.25, -0.25], abs=1e-6)
+ assert result == pytest.approx([0.75, -0.25, -0.25, -0.25], abs=1e-6)
def test_efficiency_shorter_correct_higher_advantage():
"""Among correct rollouts in a mixed group, shorter always gets higher advantage."""
- inputs = _make_group(rewards=[1.0, 1.0, 1.0, 0.0, 0.0], completion_lengths=[50, 100, 200, 80, 120])
- result = default_advantage_fn(inputs, length_penalty=_TOKENS_COMPLETION)
+ result = efficiency_shaping_advantage(
+ _make_group(rewards=[1.0, 1.0, 1.0, 0.0, 0.0], completion_lengths=[50, 100, 200, 80, 120]),
+ _TOKENS_COMPLETION,
+ )
- advs = result.advantages
- assert advs[0] > advs[1] > advs[2]
- assert all(a > 0 for a in advs[:3])
- assert all(a < 0 for a in advs[3:])
+ assert result[0] > result[1] > result[2]
+ assert all(a > 0 for a in result[:3])
+ assert all(a < 0 for a in result[3:])
def test_efficiency_zero_mean_per_group():
"""Reward shaping preserves zero-mean advantages within each group."""
- mixed = default_advantage_fn(
+ mixed = efficiency_shaping_advantage(
_make_group(rewards=[1.0, 1.0, 0.0, 1.0], completion_lengths=[10, 30, 20, 20]),
- length_penalty=_TOKENS_COMPLETION,
+ _TOKENS_COMPLETION,
)
- all_correct = default_advantage_fn(
+ all_correct = efficiency_shaping_advantage(
_make_group(rewards=[1.0, 1.0, 1.0, 1.0], completion_lengths=[10, 20, 40, 80]),
- length_penalty=_TOKENS_COMPLETION,
+ _TOKENS_COMPLETION,
)
- assert sum(mixed.advantages) == pytest.approx(0.0, abs=1e-6)
- assert sum(all_correct.advantages) == pytest.approx(0.0, abs=1e-6)
+ assert sum(mixed) == pytest.approx(0.0, abs=1e-6)
+ assert sum(all_correct) == pytest.approx(0.0, abs=1e-6)
def test_efficiency_amplification_bounded():
"""Even with extreme length outliers, reward amplification is capped at 2x."""
- inputs = _make_group(rewards=[1.0, 1.0, 0.0], completion_lengths=[1, 10000, 5000])
- result = default_advantage_fn(inputs, length_penalty=_TOKENS_COMPLETION)
+ result = efficiency_shaping_advantage(
+ _make_group(rewards=[1.0, 1.0, 0.0], completion_lengths=[1, 10000, 5000]),
+ _TOKENS_COMPLETION,
+ )
- # Shortest correct gets bonus ≈ 1, so shaped_reward ≈ 2
- # Standard reward = 1, so amplification ≈ 2x
- # shaped_rewards ≈ [2, 1, 0], baseline ≈ 1, max advantage ≈ 1
- assert result.advantages[0] < 1.0 + 1e-3
+ # Shortest correct gets bonus ≈ 1, so shaped_reward ≈ 2; baseline ≈ 1, max advantage ≈ 1
+ assert result[0] < 1.0 + 1e-3
def test_efficiency_tokens_with_tool_response_weight():
@@ -180,16 +231,15 @@ def test_efficiency_tokens_with_tool_response_weight():
"metrics": {"rlm_total_tool_response_tokens": 100},
},
]
- inputs = AdvantageInputs(rollouts=rollouts)
+ group = _views(rollouts)
# completion tokens identical (10 each) → completion-only shaping is a no-op
- result_completion_only = default_advantage_fn(inputs, length_penalty=_TOKENS_COMPLETION)
- assert result_completion_only.advantages == pytest.approx([0.0, 0.0, 0.0], abs=1e-6)
+ result_completion_only = efficiency_shaping_advantage(group, _TOKENS_COMPLETION)
+ assert result_completion_only == pytest.approx([0.0, 0.0, 0.0], abs=1e-6)
# tool-response only: costs are [200, 0, 100], mean=100, bonus is one-sided
# so only the below-mean rollout (idx 1) gets amplified; the at/above-mean tie.
- result_tool_only = default_advantage_fn(inputs, length_penalty=_TOKENS_TOOL_ONLY)
- advs = result_tool_only.advantages
+ advs = efficiency_shaping_advantage(group, _TOKENS_TOOL_ONLY)
assert advs[1] > advs[0]
assert advs[1] > advs[2]
assert advs[0] == pytest.approx(advs[2], abs=1e-6)
@@ -206,9 +256,9 @@ def test_efficiency_fractional_weight_with_int_rewards():
rollouts_float = [{**r, "reward": float(r["reward"])} for r in rollouts_int]
fractional = TokensLengthPenaltyConfig(completion_weight=0.3, tool_response_weight=0.0)
- int_result = default_advantage_fn(AdvantageInputs(rollouts=rollouts_int), length_penalty=fractional)
- float_result = default_advantage_fn(AdvantageInputs(rollouts=rollouts_float), length_penalty=fractional)
- assert int_result.advantages == pytest.approx(float_result.advantages, abs=1e-6)
+ int_result = efficiency_shaping_advantage(_views(rollouts_int), fractional)
+ float_result = efficiency_shaping_advantage(_views(rollouts_float), fractional)
+ assert int_result == pytest.approx(float_result, abs=1e-6)
def test_efficiency_zero_costs_falls_back_to_plain_grpo():
@@ -219,91 +269,199 @@ def test_efficiency_zero_costs_falls_back_to_plain_grpo():
{"reward": 1.0, "trajectory": [{"tokens": {"prompt_ids": [0], "completion_ids": list(range(10))}}]},
{"reward": 0.0, "trajectory": [{"tokens": {"prompt_ids": [0], "completion_ids": list(range(10))}}]},
]
- inputs = AdvantageInputs(rollouts=rollouts)
- result = default_advantage_fn(inputs, length_penalty=_TOKENS_TOOL_ONLY)
- expected = default_advantage_fn(inputs) # plain GRPO
- assert not any(math.isnan(a) for a in result.advantages)
- assert result.advantages == pytest.approx(expected.advantages, abs=1e-6)
+ group = _views(rollouts)
+ result = efficiency_shaping_advantage(group, _TOKENS_TOOL_ONLY)
+ expected = grpo_advantage(group) # plain GRPO
+ assert not any(math.isnan(a) for a in result)
+ assert result == pytest.approx(expected, abs=1e-6)
def test_efficiency_tokens_default_weights_match_completion_when_no_metric():
"""Default TokensLengthPenaltyConfig (1,1) reduces to completion-only when rollouts lack the metric."""
- inputs = _make_group(rewards=[1.0, 1.0, 0.0, 1.0], completion_lengths=[10, 30, 20, 20])
- result_default = default_advantage_fn(inputs, length_penalty=TokensLengthPenaltyConfig())
- result_completion = default_advantage_fn(inputs, length_penalty=_TOKENS_COMPLETION)
- assert result_default.advantages == pytest.approx(result_completion.advantages, abs=1e-6)
+ group = _make_group(rewards=[1.0, 1.0, 0.0, 1.0], completion_lengths=[10, 30, 20, 20])
+ result_default = efficiency_shaping_advantage(group, TokensLengthPenaltyConfig())
+ result_completion = efficiency_shaping_advantage(group, _TOKENS_COMPLETION)
+ assert result_default == pytest.approx(result_completion, abs=1e-6)
def test_efficiency_turns_penalty():
"""`TurnsLengthPenaltyConfig` shapes by trajectory turn count rather than token count."""
- inputs = _make_group(
- rewards=[1.0, 1.0, 0.0, 1.0],
- # token counts identical, but turns differ — turns penalty should still differentiate
- completion_lengths=[100, 100, 100, 100],
- num_turns=[1, 3, 2, 2],
+ result = efficiency_shaping_advantage(
+ _make_group(
+ rewards=[1.0, 1.0, 0.0, 1.0],
+ # token counts identical, but turns differ — turns penalty should still differentiate
+ completion_lengths=[100, 100, 100, 100],
+ num_turns=[1, 3, 2, 2],
+ ),
+ TurnsLengthPenaltyConfig(),
)
- result = default_advantage_fn(inputs, length_penalty=TurnsLengthPenaltyConfig())
# mean_correct_turns = (1+3+2)/3 = 2
# bonus = clamp(1 - [1,3,2,2]/2, 0, 1) = [0.5, 0, 0, 0]
- assert result.advantages == pytest.approx([0.625, 0.125, -0.875, 0.125], abs=1e-6)
-
-
-def _train_rollouts(rewards: list[float]) -> list[TrainRollout]:
- """Wrap a list of rewards into ``TrainRollout``\\ s sharing a single
- ``group_id`` — ``assign_advantages`` works on one group at a time
- (the sink groups by ``group_id`` upstream)."""
- gid = uuid.uuid4()
- return [
- TrainRollout(
- raw={"reward": r, "trajectory": []},
- env_name="test",
- example_id=0,
- group_id=gid,
- policy_version=0,
- off_policy_steps=0,
+ assert result == pytest.approx([0.625, 0.125, -0.875, 0.125], abs=1e-6)
+
+
+def test_linear_penalty_is_grpo_plus_centered_penalty():
+ """The linear penalty is a separate additive advantage: grpo_advantage + length_penalty_advantage
+ is identical to folding the penalty into the reward before centering (centering is linear)."""
+ group = _make_group(rewards=[1.0, 1.0, 0.0, 0.0], completion_lengths=[100, 200, 100, 200])
+ cfg = LinearLengthPenaltyConfig(coef=0.25)
+ summed = [a + p for a, p in zip(grpo_advantage(group), length_penalty_advantage(group, cfg, 1000), strict=True)]
+
+ # folded reference: (reward - penalty) centered by the plain mean
+ rewards = [1.0, 1.0, 0.0, 0.0]
+ pass_rate = sum(rewards) / len(rewards) # 0.5
+ penalty = [0.25 * pass_rate * (length / 1000) for length in (100, 200, 100, 200)]
+ folded_raw = [r - p for r, p in zip(rewards, penalty)]
+ mean = sum(folded_raw) / len(folded_raw)
+ folded = [x - mean for x in folded_raw]
+
+ assert summed == pytest.approx(folded, abs=1e-6)
+ assert summed == pytest.approx([0.50625, 0.49375, -0.49375, -0.50625], abs=1e-6)
+ assert sum(summed) == pytest.approx(0.0, abs=1e-6)
+ # shorter beats longer within each outcome
+ assert summed[0] > summed[1]
+ assert summed[2] > summed[3]
+
+
+def test_length_penalty_advantage_zero_pass_rate_is_zero():
+ """A never-solved group (mean reward 0) has zero penalty everywhere — it adds nothing to GRPO."""
+ group = _make_group(rewards=[0.0, 0.0, 0.0], completion_lengths=[10, 20, 30])
+ penalty = length_penalty_advantage(group, LinearLengthPenaltyConfig(coef=0.25), max_seq_len=100)
+ assert penalty == pytest.approx([0.0, 0.0, 0.0], abs=1e-6)
+
+
+def test_length_penalty_advantage_uniform_lengths_is_zero():
+ """Equal lengths → uniform penalty → the centered term is zero, so it is a no-op on GRPO."""
+ group = _make_group(rewards=[1.0, 1.0, 0.0, 0.0], completion_lengths=[100, 100, 100, 100])
+ penalty = length_penalty_advantage(group, LinearLengthPenaltyConfig(coef=0.25), max_seq_len=100)
+ assert penalty == pytest.approx([0.0, 0.0, 0.0, 0.0], abs=1e-6)
+
+
+def test_length_penalty_advantage_gate_by_correctness():
+ """gate_by_correctness penalizes only correct rollouts; with equal lengths the centered
+ penalty term is non-zero (correct rollouts pushed down, incorrect ones up)."""
+ group = _make_group(rewards=[1.0, 1.0, 0.0, 0.0], completion_lengths=[100, 100, 100, 100])
+ penalty = length_penalty_advantage(
+ group, LinearLengthPenaltyConfig(coef=0.25, gate_by_correctness=True), max_seq_len=100
+ )
+ # penalty_i = 0.25 * 0.5 * 1 * reward = [0.125, 0.125, 0, 0]; centered = mean - penalty
+ assert penalty == pytest.approx([-0.0625, -0.0625, 0.0625, 0.0625], abs=1e-6)
+
+
+def test_linear_penalty_with_length_weighted_baseline_matches_folded():
+ """With length_weighted_baseline, GRPO + penalty still equals folding the penalty
+ into the reward before length-weighted centering (the original #2702 behavior)."""
+ rewards = [1.0, 1.0, 0.0, 0.0]
+ lengths = [100, 200, 100, 300]
+ group = _make_group(rewards=rewards, completion_lengths=lengths)
+ cfg = LinearLengthPenaltyConfig(coef=0.25)
+ summed = [
+ a + p
+ for a, p in zip(
+ grpo_advantage(group, length_weighted_baseline=True),
+ length_penalty_advantage(group, cfg, 1000, length_weighted_baseline=True),
+ strict=True,
)
- for r in rewards
]
+ # folded reference: (reward - penalty) centered by the token-length-weighted mean
+ pass_rate = sum(rewards) / len(rewards)
+ penalty = [0.25 * pass_rate * (length / 1000) for length in lengths]
+ folded_raw = [r - p for r, p in zip(rewards, penalty)]
+ lw_mean = sum(length * x for length, x in zip(lengths, folded_raw)) / sum(lengths)
+ folded = [x - lw_mean for x in folded_raw]
-def test_assign_advantages_writes_field():
- rollouts = _train_rollouts([1.0, 0.5, 0.8])
- fn = setup_advantage_fn(DefaultAdvantageConfig())
- assign_advantages(rollouts, fn)
- advs = [r.advantage for r in rollouts]
- assert sum(advs) == pytest.approx(0.0, abs=1e-6)
+ assert summed == pytest.approx(folded, abs=1e-6)
+ # advantages are length-weighted-zero, like the folded original (float32 accumulation)
+ assert sum(length * a for length, a in zip(lengths, summed)) == pytest.approx(0.0, abs=1e-3)
+
+
+def test_length_penalty_advantage_requires_max_seq_len():
+ """The linear penalty's denominator is orchestrator.seq_len — missing it is an error, not a guess."""
+ group = _make_group(rewards=[1.0, 0.0], completion_lengths=[100, 100])
+ with pytest.raises(ValueError, match="max_seq_len"):
+ length_penalty_advantage(group, LinearLengthPenaltyConfig(), max_seq_len=None)
-def test_assign_advantages_without_fn_is_reward():
- """``advantage_fn=None`` falls back to ``advantage = reward``."""
- rollouts = _train_rollouts([1.0, 0.5, 0.8])
- assign_advantages(rollouts, None)
- assert [r.advantage for r in rollouts] == [1.0, 0.5, 0.8]
+def test_grpo_length_weighted_baseline():
+ """The length-weighted baseline centers by per-token expected reward:
+ sum(len_i * reward_i) / sum(len_i) instead of the plain mean."""
+ group = _make_group(rewards=[1.0, 0.0], completion_lengths=[100, 300])
+ result = grpo_advantage(group, length_weighted_baseline=True)
+ # baseline = (100*1 + 300*0) / 400 = 0.25
+ assert result == pytest.approx([0.75, -0.25], abs=1e-6)
+ # advantages are length-weighted-zero (not mean-zero)
+ assert (100 * result[0] + 300 * result[1]) == pytest.approx(0.0, abs=1e-6)
-def test_assign_advantages_singleton_group_is_zero():
+
+def test_grpo_algorithm_sums_linear_penalty_end_to_end():
+ """build_algorithm injects max_seq_len; GRPOAlgorithm.score_group writes
+ grpo_advantage + length_penalty_advantage onto each rollout."""
+ config = AlgorithmConfig.model_validate(
+ {"advantage": {"type": "grpo", "length_penalty": {"type": "linear", "coef": 0.25}}}
+ )
+ algorithm = build_algorithm(config, policy_pool=None, renderer=None, max_seq_len=1000)
+
+ rollouts = [
+ _train_rollout(_make_rollout(reward, completion_len=length))
+ for reward, length in [(1.0, 100), (1.0, 200), (0.0, 100), (0.0, 200)]
+ ]
+ asyncio.run(algorithm.score_group([RolloutView(r) for r in rollouts]))
+
+ # single-token samples → each rollout's stream is its scalar advantage
+ advantages = [r.advantages[0] for r in rollouts]
+ assert advantages == pytest.approx([0.50625, 0.49375, -0.49375, -0.50625], abs=1e-6)
+
+
+def test_rollout_view_assign_advantages_broadcasts_scalar():
+ """A scalar broadcasts uniformly over the rollout's completion tokens."""
+ rollout = _train_rollout({"reward": 0.0, "trajectory": []}, completion_ids=(2, 3))
+ RolloutView(rollout).assign_advantages(0.7)
+ assert rollout.advantages == [0.7, 0.7]
+
+
+def test_rollout_view_assign_advantages_rejects_misaligned():
+ rollout = _train_rollout({"reward": 0.0, "trajectory": []}, completion_ids=(2, 3))
+ with pytest.raises(ValueError, match="align"):
+ RolloutView(rollout).assign_advantages([0.5])
+
+
+def test_apply_advantage_fn_broadcasts_group_norm():
+ rollouts = [_train_rollout({"reward": r, "trajectory": []}, completion_ids=(2, 3)) for r in (1.0, 0.5, 0.8)]
+ apply_advantage_fn([RolloutView(r) for r in rollouts], grpo_advantage)
+ streams = [r.advantages for r in rollouts]
+ # group credit broadcasts uniformly over each rollout's completion tokens
+ assert all(len(s) == 2 and s[0] == s[1] for s in streams)
+ assert sum(s[0] for s in streams) == pytest.approx(0.0, abs=1e-6)
+
+
+def test_apply_advantage_fn_singleton_group_is_zero():
"""A group of size 1 has reward == mean, so its advantage is 0."""
- rollouts = _train_rollouts([0.7])
- fn = setup_advantage_fn(DefaultAdvantageConfig())
- assign_advantages(rollouts, fn)
- assert rollouts[0].advantage == pytest.approx(0.0, abs=1e-6)
+ rollouts = [_train_rollout({"reward": 0.7, "trajectory": []}, completion_ids=(2, 3))]
+ apply_advantage_fn([RolloutView(r) for r in rollouts], grpo_advantage)
+ assert rollouts[0].advantages == pytest.approx([0.0, 0.0], abs=1e-6)
-def test_setup_advantage_fn_with_custom_config():
- config = CustomAdvantageConfig(
- import_path="tests.unit.orchestrator.test_advantage._dummy_custom_advantage",
- kwargs={"scale": 2.0},
+def test_custom_advantage_algorithm():
+ config = AlgorithmConfig.model_validate(
+ {
+ "advantage": {
+ "type": "custom",
+ "import_path": "tests.unit.orchestrator.test_advantage._dummy_custom_advantage",
+ "kwargs": {"scale": 2.0},
+ }
+ }
)
- advantage_fn = setup_advantage_fn(config)
+ algorithm = CustomAlgorithm(config.advantage, policy_pool=None, renderer=None)
- inputs = _make_group(rewards=[1.0, 0.5, 0.8], completion_lengths=[10, 12, 8])
+ group = _make_group(rewards=[1.0, 0.5, 0.8], completion_lengths=[10, 12, 8])
- result = advantage_fn(inputs)
- assert isinstance(result, AdvantageOutputs)
- assert result.advantages == pytest.approx([2.0, 1.0, 1.6], abs=1e-6)
+ result = algorithm.advantage_fn(group)
+ assert result == pytest.approx([2.0, 1.0, 1.6], abs=1e-6)
-def _dummy_custom_advantage(inputs: AdvantageInputs, scale: float = 1.0) -> AdvantageOutputs:
- """A simple custom advantage for testing."""
- return AdvantageOutputs(advantages=[r["reward"] * scale for r in inputs.rollouts])
+def _dummy_custom_advantage(group: list[RolloutView], scale: float = 1.0) -> list[float]:
+ """A simple custom advantage for testing — one scalar per rollout."""
+ return [view.reward * scale for view in group]
diff --git a/tests/unit/orchestrator/test_algorithms.py b/tests/unit/orchestrator/test_algorithms.py
new file mode 100644
index 0000000000..b8b5409733
--- /dev/null
+++ b/tests/unit/orchestrator/test_algorithms.py
@@ -0,0 +1,316 @@
+import asyncio
+import uuid
+from unittest.mock import MagicMock
+
+import pytest
+import verifiers as vf
+
+from prime_rl.configs.algorithm import AlgorithmConfig, FrozenModelConfig
+from prime_rl.orchestrator.algo import EchoAlgorithm, stamp_advantages, stamp_loss_routing
+from prime_rl.orchestrator.trajectories import interleave_rollout
+from prime_rl.orchestrator.types import RolloutView, TrainRollout
+from prime_rl.transport.types import TrainingSample
+
+FROZEN = {"name": "org/ref-model", "base_url": ["http://ref:8001/v1"]}
+
+
+def _ref_kind(ref):
+ """Collapse a resolved reference to a comparable marker."""
+ return "frozen" if isinstance(ref, FrozenModelConfig) else ref
+
+
+@pytest.mark.parametrize(
+ ("advantage_type", "model", "source", "advantage_model", "action_loss_type"),
+ [
+ ("grpo", None, "policy", None, "rl"),
+ ("max_rl", None, "policy", None, "rl"),
+ ("opd", FROZEN, "policy", "frozen", "ref_kl"),
+ ("sft", FROZEN, "frozen", None, "ce"),
+ ("opsd", None, "policy", "policy", "ref_kl"),
+ ("echo", None, "policy", None, "rl"),
+ ],
+)
+def test_type_defaults_are_the_vetted_algorithms(advantage_type, model, source, advantage_model, action_loss_type):
+ algo = AlgorithmConfig(advantage={"type": advantage_type}, model=model)
+ assert _ref_kind(algo.sampling.source) == source
+ assert algo.advantage.type == advantage_type
+ assert _ref_kind(getattr(algo.advantage, "model", None)) == advantage_model
+ assert algo.advantage.action_loss_type == action_loss_type
+
+
+def test_echo_roles_replace_the_default_table():
+ algo = AlgorithmConfig(advantage={"type": "echo", "roles": {"user": {"alpha": 0.5}}})
+ assert algo.advantage.type == "echo"
+ assert algo.advantage.roles.user.alpha == 0.5
+ # Setting any role replaces the whole table: the tool default is gone
+ assert algo.advantage.roles.tool is None
+
+
+def test_echo_defaults_to_tool_bodies():
+ algo = AlgorithmConfig(advantage={"type": "echo"})
+ assert algo.advantage.roles.tool.alpha == 0.1
+ assert algo.advantage.roles.system is None
+ assert algo.advantage.roles.user is None
+ assert algo.advantage.roles.assistant is None
+
+
+def test_echo_roles_require_at_least_one():
+ with pytest.raises(ValueError, match="at least one role"):
+ AlgorithmConfig(advantage={"type": "echo", "roles": {}})
+
+
+def test_opd_requires_teacher():
+ with pytest.raises(ValueError, match="needs a teacher"):
+ AlgorithmConfig(advantage={"type": "opd"})
+
+
+def test_sft_requires_teacher():
+ with pytest.raises(ValueError, match="needs a teacher to sample rollouts from"):
+ AlgorithmConfig(advantage={"type": "sft"})
+
+
+def test_teacher_aliases_model_shorthand():
+ algo = AlgorithmConfig.model_validate({"advantage": {"type": "opd"}, "teacher": FROZEN})
+ assert isinstance(algo.advantage.model, FrozenModelConfig)
+ assert algo.advantage.model.name == "org/ref-model"
+
+
+def test_model_shorthand_without_target_errors():
+ with pytest.raises(ValueError, match="no component reference accepts it"):
+ AlgorithmConfig(model=FROZEN)
+
+
+def test_model_shorthand_redundant_but_consistent_is_accepted():
+ algo = AlgorithmConfig(model=FROZEN, advantage={"type": "opd", "model": FROZEN})
+ assert isinstance(algo.advantage.model, FrozenModelConfig)
+
+
+def test_opd_rejects_policy():
+ with pytest.raises(ValueError, match="degenerate"):
+ AlgorithmConfig(advantage={"type": "opd"}, model="policy")
+
+
+def test_rl_loss_type_incompatible_with_frozen_sampling():
+ with pytest.raises(ValueError, match="sampling.source is a frozen model"):
+ AlgorithmConfig(sampling={"source": FROZEN}, advantage={"type": "grpo"})
+
+
+def _make_sample(ce_weights: list[float] | None = None) -> TrainingSample:
+ return TrainingSample(
+ prompt_ids=[1, 2],
+ prompt_mask=[False, False],
+ completion_ids=[3, 4, 5, 6],
+ completion_mask=[True, True, False, True],
+ completion_logprobs=[-0.1, -0.2, 0.0, -0.3],
+ completion_temperatures=[],
+ env_name="test-env",
+ ce_weights=ce_weights,
+ )
+
+
+def test_stamp_loss_routing_uniform_rl():
+ sample = _make_sample()
+ stamp_loss_routing(sample, "rl")
+ # Hot path: absent streams mean rl weight 1.0 on the loss mask
+ assert sample.rl_weights is None
+ assert sample.ce_weights is None
+ assert sample.ref_kl_weights is None
+
+
+def test_stamp_loss_routing_ref_kl_action():
+ sample = _make_sample()
+ stamp_loss_routing(sample, "ref_kl")
+ # Action tokens (completion_mask True) feed the ref_kl component; rl is off
+ assert sample.rl_weights == [0.0] * 6
+ assert sample.ref_kl_weights == [0.0, 0.0] + [1.0, 1.0, 0.0, 1.0]
+ assert sample.ce_weights is None
+
+
+def test_stamp_loss_routing_ce_action():
+ sample = _make_sample()
+ stamp_loss_routing(sample, "ce")
+ assert sample.rl_weights == [0.0] * 6
+ assert sample.ce_weights == [0.0, 0.0] + [1.0, 1.0, 0.0, 1.0]
+ assert sample.ref_kl_weights is None
+
+
+def test_stamp_loss_routing_keeps_algorithm_written_ce_stream():
+ # Echo writes ce_weights directly at group time (observation at
+ # completion index 2, outside completion_mask); rl routing must not
+ # clobber it — the rl component still ships no streams (hot path).
+ sample = _make_sample(ce_weights=[0.0, 0.0] + [0.0, 0.0, 0.1, 0.0])
+ stamp_loss_routing(sample, "rl")
+ assert sample.rl_weights is None
+ assert sample.ce_weights == [0.0, 0.0] + [0.0, 0.0, 0.1, 0.0]
+ assert sample.ref_kl_weights is None
+
+
+def test_stamp_loss_routing_merges_action_weights_into_ce_stream():
+ # A ce-action algorithm that also weighted observation tokens: action
+ # tokens merge into the existing stream instead of replacing it.
+ sample = _make_sample(ce_weights=[0.0, 0.0] + [0.0, 0.0, 0.1, 0.0])
+ stamp_loss_routing(sample, "ce")
+ assert sample.rl_weights == [0.0] * 6
+ assert sample.ce_weights == [0.0, 0.0] + [1.0, 1.0, 0.1, 1.0]
+ assert sample.ref_kl_weights is None
+
+
+def _make_rollout(
+ samples: list[TrainingSample],
+ advantages: list[float] | None = None,
+ raw: vf.RolloutOutput | None = None,
+) -> TrainRollout:
+ return TrainRollout(
+ raw=raw if raw is not None else {},
+ env_name="test-env",
+ example_id=0,
+ group_id=uuid.uuid4(),
+ policy_version=0,
+ off_policy_steps=0,
+ samples=samples,
+ advantages=advantages,
+ )
+
+
+def test_stamp_advantages_pads_prompt():
+ rollout = _make_rollout([_make_sample()], advantages=[0.5, -0.5, 0.0, 1.0])
+ stamp_advantages(rollout)
+ # 2 prompt positions padded with 0.0 + 4 completion-aligned advantages
+ assert rollout.samples[0].advantages == [0.0, 0.0, 0.5, -0.5, 0.0, 1.0]
+
+
+def test_stamp_advantages_slices_across_samples():
+ samples = [_make_sample(), _make_sample()]
+ rollout = _make_rollout(samples, advantages=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
+ stamp_advantages(rollout)
+ assert rollout.samples[0].advantages == [0.0, 0.0, 1.0, 2.0, 3.0, 4.0]
+ assert rollout.samples[1].advantages == [0.0, 0.0, 5.0, 6.0, 7.0, 8.0]
+
+
+def test_stamp_advantages_no_credit_ships_none():
+ rollout = _make_rollout([_make_sample()])
+ stamp_advantages(rollout)
+ assert rollout.samples[0].advantages is None
+
+
+def test_stamp_advantages_rejects_misaligned():
+ rollout = _make_rollout([_make_sample()], advantages=[0.5])
+ with pytest.raises(ValueError, match="align"):
+ stamp_advantages(rollout)
+
+
+def _echo_algorithm(roles: dict | None = None, filter_fn=None) -> EchoAlgorithm:
+ advantage: dict = {"type": "echo"}
+ if roles is not None:
+ advantage["roles"] = roles
+ algo = EchoAlgorithm(AlgorithmConfig(advantage=advantage).advantage, MagicMock(), MagicMock())
+ algo.filter_fn = filter_fn
+ return algo
+
+
+def _two_step_rollout(attribution: dict | None = None) -> vf.RolloutOutput:
+ def step(prompt_ids, completion_ids, logprobs, prompt_attribution=None):
+ tokens = vf.TrajectoryStepTokens(
+ prompt_ids=prompt_ids,
+ prompt_mask=[0] * len(prompt_ids),
+ completion_ids=completion_ids,
+ completion_mask=[1] * len(completion_ids),
+ completion_logprobs=logprobs,
+ overlong_prompt=False,
+ is_truncated=False,
+ )
+ if prompt_attribution is not None:
+ tokens["prompt_attribution"] = prompt_attribution
+ return vf.TrajectoryStep(
+ prompt=[{"role": "user", "content": "U"}],
+ completion=[{"role": "assistant", "content": "A"}],
+ response=MagicMock(),
+ tokens=tokens,
+ reward=None,
+ advantage=None,
+ is_truncated=False,
+ trajectory_id="1",
+ extras={},
+ )
+
+ # Renderer rollouts carry attribution on every step; the first step's
+ # prompt never lands as observation tokens, so a minimal one suffices.
+ first_attribution = {"message_indices": [0, 0], "message_roles": ["user"]} if attribution is not None else None
+ return vf.RolloutOutput(
+ example_id=0,
+ reward=1.0,
+ trajectory=[
+ step([1, 2], [3, 4], [-0.1, -0.2], prompt_attribution=first_attribution),
+ # Extension: prompt re-includes [1,2,3,4]; tokens [5,6] are the
+ # env's observation; [7,8] the next action.
+ step([1, 2, 3, 4, 5, 6], [7, 8], [-0.3, -0.4], prompt_attribution=attribution),
+ ],
+ error=None,
+ )
+
+
+def _echo_rollout(output: vf.RolloutOutput) -> TrainRollout:
+ samples = interleave_rollout(output, env_name="test-env")
+ assert samples is not None
+ return _make_rollout(samples, raw=output)
+
+
+def test_echo_weights_observations_by_role():
+ # Span tokens [5,6] (positions 4,5) belong to a tool message; is_content
+ # excludes the wrap token, so only the body token gets the tool weight.
+ attribution = {
+ "message_indices": [0, 0, 1, 1, 2, 2],
+ "message_roles": ["user", "assistant", "tool"],
+ "is_content": [True, True, True, True, False, True],
+ }
+ rollout = _echo_rollout(_two_step_rollout(attribution))
+ algo = _echo_algorithm() # the default table: tool bodies at 0.1
+ asyncio.run(algo.score_rollout(RolloutView(rollout)))
+ sample = rollout.samples[0]
+ assert sample.completion_ids == [3, 4, 5, 6, 7, 8]
+ # [3,4] step-1 action, [5,6] observation, [7,8] step-2 action
+ assert sample.ce_weights == [0.0, 0.0] + [0.0, 0.0, 0.0, 0.1, 0.0, 0.0]
+ assert sample.completion_mask == [True, True, False, False, True, True]
+
+ # Without is_content, whole messages count; each role carries its own weight.
+ attribution = {"message_indices": [0, 0, 1, 1, 2, 3], "message_roles": ["user", "assistant", "tool", "user"]}
+ rollout = _echo_rollout(_two_step_rollout(attribution))
+ algo = _echo_algorithm(roles={"tool": {"alpha": 0.1}, "user": {"alpha": 0.05}})
+ asyncio.run(algo.score_rollout(RolloutView(rollout)))
+ assert rollout.samples[0].ce_weights == [0.0, 0.0] + [0.0, 0.0, 0.1, 0.05, 0.0, 0.0]
+
+ # MITO rollouts carry no attribution: loud error, not a silent no-op.
+ with pytest.raises(ValueError, match="attribution"):
+ asyncio.run(_echo_algorithm().score_rollout(RolloutView(_echo_rollout(_two_step_rollout()))))
+
+
+def test_echo_filter_narrows_selection():
+ attribution = {"message_indices": [0, 0, 1, 1, 2, 2], "message_roles": ["user", "assistant", "tool"]}
+
+ def keep_last_only(output):
+ # One keep-mask per step over prompt+completion; drops span position 4.
+ return [[True] * 4, [True, True, True, True, False, True, True, True]]
+
+ rollout = _echo_rollout(_two_step_rollout(attribution))
+ algo = _echo_algorithm(filter_fn=keep_last_only)
+ asyncio.run(algo.score_rollout(RolloutView(rollout)))
+ assert rollout.samples[0].ce_weights == [0.0, 0.0] + [0.0, 0.0, 0.0, 0.1, 0.0, 0.0]
+
+ # Shape violations fail loudly: wrong step count, wrong per-step length.
+ rollout = _echo_rollout(_two_step_rollout(attribution))
+ with pytest.raises(ValueError, match="per trajectory step"):
+ asyncio.run(_echo_algorithm(filter_fn=lambda output: [[True] * 4]).score_rollout(RolloutView(rollout)))
+ with pytest.raises(ValueError, match="prompt\\+completion"):
+ asyncio.run(
+ _echo_algorithm(filter_fn=lambda output: [[True] * 4, [True] * 6]).score_rollout(RolloutView(rollout))
+ )
+
+
+def test_interleave_records_obs_spans():
+ samples = interleave_rollout(_two_step_rollout(), env_name="test-env")
+ assert samples is not None
+ # The step-2 prompt extension [5,6] lands at completion positions 2-3,
+ # sourced from step 1's prompt at offset 4, length 2.
+ assert samples[0].obs_spans == [[2, 1, 4, 2]]
+ # Provenance only — no algorithm wrote a ce stream.
+ assert samples[0].ce_weights is None
diff --git a/tests/unit/orchestrator/test_batch.py b/tests/unit/orchestrator/test_batch.py
index bbe0e61724..720148c3ce 100644
--- a/tests/unit/orchestrator/test_batch.py
+++ b/tests/unit/orchestrator/test_batch.py
@@ -25,7 +25,8 @@ def _routed_experts(data, dtype=np.uint8):
def make_training_example():
def _make_training_example(
temperature: float = 1.0,
- training_mode: str = "rl",
+ ce_weights: list[float] | None = None,
+ rl_weights: list[float] | None = None,
env_name: str = "test-env",
) -> TrainingSample:
return TrainingSample(
@@ -35,10 +36,11 @@ def _make_training_example(
completion_mask=[True, True],
completion_logprobs=[-0.1, -0.2],
completion_temperatures=[temperature, temperature], # Per-token temperatures
- teacher_logprobs=[0.0, 0.0, 0.0, 0.0],
- advantage=1.0,
+ ref_logprobs=[0.0, 0.0, 0.0, 0.0],
+ advantages=[0.0, 0.0, 1.0, 1.0],
env_name=env_name,
- training_mode=training_mode,
+ ce_weights=ce_weights,
+ rl_weights=rl_weights,
)
return _make_training_example
@@ -54,7 +56,7 @@ def make_sized_training_example(length: int, env_name: str = "test-env") -> Trai
completion_mask=[True],
completion_logprobs=[-0.1],
completion_temperatures=[1.0],
- advantage=1.0,
+ advantages=[1.0] * length,
env_name=env_name,
)
@@ -83,6 +85,58 @@ def make_flops_config():
)
+def test_training_sample_requires_env_name():
+ with pytest.raises(TypeError, match="env_name"):
+ TrainingSample(
+ prompt_ids=[1, 2],
+ prompt_mask=[False, False],
+ completion_ids=[3, 4],
+ completion_mask=[True, True],
+ completion_logprobs=[-0.1, -0.2],
+ completion_temperatures=[1.0, 1.0],
+ )
+
+
+@pytest.mark.parametrize(
+ ("rollout_count", "num_train_workers", "expected_batches_per_worker"), [(4, 2, 2), (5, 2, 3), (7, 1, 7), (11, 4, 3)]
+)
+def test_prepare_batch_balances_micro_batches_across_workers(
+ make_training_example, rollout_count, num_train_workers, expected_batches_per_worker
+):
+ examples = [make_training_example() for i in range(rollout_count)]
+
+ batches_per_gpu = prepare_batch(
+ rollouts=examples,
+ seq_len=4,
+ num_train_workers=num_train_workers,
+ idxs=[0] * rollout_count,
+ num_loras=1,
+ bin_cost=build_bin_cost(None),
+ )
+
+ assert all(len(worker_batches) == expected_batches_per_worker for worker_batches in batches_per_gpu)
+
+ flat_batches = _flatten_batches(batches_per_gpu)
+ assert len(examples) <= len(flat_batches) < len(examples) + num_train_workers
+
+ # Identify real vs padding batches by content, not position — the packer
+ # distributes by workload, so a dummy can land anywhere in the order.
+ real_batches = [batch for batch in flat_batches if _has_loss_tokens(batch)]
+ dummy_batches = [batch for batch in flat_batches if not _has_loss_tokens(batch)]
+ assert len(real_batches) == len(examples)
+
+ # Verify real rollouts have expected non-zero advantages and loss mask
+ # (the advantage stream is 0.0 on prompt positions, the scalar on completion)
+ for batch in real_batches:
+ assert sum(1 for advantage in batch.advantages if advantage != 0.0) == 2
+ assert sum(1 for loss_mask in batch.loss_mask if loss_mask) == 2
+
+ # Verify padded batches have zero advantages and loss mask
+ for batch in dummy_batches:
+ assert sum(1 for advantage in batch.advantages if advantage != 0.0) == 0
+ assert sum(1 for loss_mask in batch.loss_mask if loss_mask) == 0
+
+
def test_randomized_packing_invariants():
rng = np.random.default_rng(0)
@@ -158,25 +212,6 @@ def test_pad_micro_batch_preserves_explicit_sequence_lengths(make_training_examp
assert padded.loss_mask[-2:] == [False, False]
-def test_prepare_batch_does_not_pack_mixed_training_mode(make_training_example):
- rl_example = make_training_example(training_mode="rl")
- sft_example = make_training_example(training_mode="sft")
-
- batches_per_gpu = prepare_batch(
- rollouts=[rl_example, sft_example],
- seq_len=16,
- num_train_workers=1,
- idxs=[0, 0],
- num_loras=1,
- bin_cost=build_bin_cost(None),
- )
-
- flat_batches = _flatten_batches(batches_per_gpu)
- assert len(flat_batches) == 2
- assert {batch.training_mode for batch in flat_batches} == {"rl", "sft"}
- assert [batch.sequence_lengths for batch in flat_batches] == [[4], [4]]
-
-
def test_split_to_align_avoids_dummy_micro_batches():
examples = [make_sized_training_example(length) for length in [6, 6, 5, 5, 4, 4]]
@@ -244,6 +279,131 @@ def test_flop_aware_split_to_align_splits_heaviest_flop_bin():
assert sum(len(batch.sequence_lengths) > 1 for batch in real_batches) == 3
+def test_prepare_sample_propagates_weight_streams(make_training_example):
+ example = make_training_example(ce_weights=[0.0, 0.0, 1.0, 1.0], rl_weights=[0.0, 0.0, 0.0, 0.0])
+
+ micro_batch = prepare_sample(example, seq_len=16)
+
+ assert micro_batch.ce_weights == [0.0, 0.0, 1.0, 1.0]
+ assert micro_batch.rl_weights == [0.0, 0.0, 0.0, 0.0]
+
+
+def test_prepare_sample_uniform_rl_keeps_streams_none(make_training_example):
+ micro_batch = prepare_sample(make_training_example(), seq_len=16)
+
+ assert micro_batch.rl_weights is None
+ assert micro_batch.ce_weights is None
+ assert micro_batch.ref_kl_weights is None
+
+
+@pytest.mark.parametrize("streams_on_longer", [True, False])
+def test_prepare_batch_packs_mixed_components(make_training_example, streams_on_longer):
+ """Component membership is per token, so samples feeding different
+ components pack together. The stream-less sample's positions must backfill
+ with the stream defaults (rl 1.0, ce 0.0) on whichever side of the pack
+ boundary it lands — a wrong-side backfill silently reroutes tokens between
+ components while keeping every array length-aligned."""
+ longer = TrainingSample(
+ prompt_ids=[1, 2, 3],
+ prompt_mask=[False] * 3,
+ completion_ids=[4, 5, 6],
+ completion_mask=[True] * 3,
+ completion_logprobs=[-0.1] * 3,
+ completion_temperatures=[1.0] * 3,
+ advantages=[0.0] * 3 + [1.0] * 3,
+ env_name="test-env",
+ ce_weights=[0.0, 0.0, 0.0, 1.0, 1.0, 1.0] if streams_on_longer else None,
+ rl_weights=[0.0] * 6 if streams_on_longer else None,
+ )
+ shorter = make_training_example(
+ ce_weights=None if streams_on_longer else [0.0, 0.0, 1.0, 1.0],
+ rl_weights=None if streams_on_longer else [0.0, 0.0, 0.0, 0.0],
+ )
+
+ batches_per_gpu = prepare_batch(
+ rollouts=[longer, shorter],
+ seq_len=16,
+ num_train_workers=1,
+ idxs=[0, 0],
+ num_loras=1,
+ bin_cost=build_bin_cost(None),
+ )
+
+ flat_batches = _flatten_batches(batches_per_gpu)
+ assert len(flat_batches) == 1
+ batch = flat_batches[0]
+ # FFD places the longer sample first; every stream value must sit at its
+ # sample's offset, with the stream-less side backfilled.
+ if streams_on_longer:
+ assert batch.rl_weights == [0.0] * 6 + [1.0] * 4
+ assert batch.ce_weights == [0.0, 0.0, 0.0, 1.0, 1.0, 1.0] + [0.0] * 4
+ else:
+ assert batch.rl_weights == [1.0] * 6 + [0.0] * 4
+ assert batch.ce_weights == [0.0] * 6 + [0.0, 0.0, 1.0, 1.0]
+
+
+@pytest.mark.parametrize("refs_on_longer", [True, False])
+def test_prepare_batch_aligns_ref_logprobs_in_mixed_bins(make_training_example, refs_on_longer):
+ """Packing a ref-bearing sample (e.g. OPD) with a ref-less one (e.g. GRPO)
+ must keep ``ref_logprobs`` position-aligned with ``input_ids`` — placeholder
+ 0.0s on the ref-less tokens, both when the bin gains refs after ref-less
+ content (backfill) and when ref-less content lands in a ref-bearing bin."""
+ longer = TrainingSample(
+ prompt_ids=[1, 2, 3],
+ prompt_mask=[False] * 3,
+ completion_ids=[4, 5, 6],
+ completion_mask=[True] * 3,
+ completion_logprobs=[-0.1] * 3,
+ completion_temperatures=[1.0] * 3,
+ ref_logprobs=[-1.5] * 6 if refs_on_longer else None,
+ advantages=[0.0] * 3 + [1.0] * 3,
+ env_name="test-env",
+ )
+ shorter = make_training_example()
+ shorter.ref_logprobs = None if refs_on_longer else [-1.5] * 4
+
+ batches_per_gpu = prepare_batch(
+ rollouts=[longer, shorter],
+ seq_len=16,
+ pad_to_multiple_of=1,
+ num_train_workers=1,
+ idxs=[0, 0],
+ num_loras=1,
+ bin_cost=build_bin_cost(None),
+ )
+ flat_batches = _flatten_batches(batches_per_gpu)
+ assert len(flat_batches) == 1 # both samples share one bin
+ bin_content = flat_batches[0]
+ assert len(bin_content.ref_logprobs) == len(bin_content.input_ids)
+ # FFD places the longer sample first; refs must sit at their sample's offset
+ if refs_on_longer:
+ assert bin_content.ref_logprobs == [-1.5] * 6 + [0.0] * 4
+ else:
+ assert bin_content.ref_logprobs == [0.0] * 6 + [-1.5] * 4
+
+
+def test_prepare_sample_with_routed_experts():
+ """Routed experts are passed through prepare_sample and match input_ids length."""
+ # 2 prompt + 2 completion = 4 tokens, 2 layers, topk=2
+ routed_experts = [[[0, 1], [2, 3]], [[4, 5], [6, 7]], [[0, 2], [1, 3]], [[1, 0], [3, 2]]]
+ routed_payload = _routed_experts(routed_experts)
+ sample = TrainingSample(
+ prompt_ids=[1, 2],
+ prompt_mask=[False, False],
+ completion_ids=[3, 4],
+ completion_mask=[True, True],
+ completion_logprobs=[-0.1, -0.2],
+ completion_temperatures=[1.0, 1.0],
+ advantages=[0.0, 0.0, 1.0, 1.0],
+ env_name="test-env",
+ routed_experts=routed_payload,
+ )
+
+ micro_batch = prepare_sample(sample, seq_len=8)
+ assert micro_batch.routed_experts is not None
+ assert micro_batch.routed_experts == routed_payload
+
+
def test_prepare_sample_truncates_routed_experts():
"""Routed experts are truncated to seq_len when input exceeds it."""
routed_experts = [[[0, 1]], [[2, 3]], [[4, 5]], [[6, 7]]]
@@ -256,7 +416,7 @@ def test_prepare_sample_truncates_routed_experts():
completion_mask=[True, True],
completion_logprobs=[-0.1, -0.2],
completion_temperatures=[1.0, 1.0],
- advantage=1.0,
+ advantages=[0.0, 0.0, 1.0, 1.0],
env_name="test-env",
routed_experts=routed_payload,
)
@@ -265,3 +425,20 @@ def test_prepare_sample_truncates_routed_experts():
assert micro_batch.routed_experts is not None
assert micro_batch.routed_experts == expected_payload
assert micro_batch.env_names == ["test-env"] * 3
+
+
+def test_prepare_sample_none_routed_experts():
+ """When routed_experts is None, micro_batch.routed_experts is None."""
+ sample = TrainingSample(
+ prompt_ids=[1, 2],
+ prompt_mask=[False, False],
+ completion_ids=[3, 4],
+ completion_mask=[True, True],
+ completion_logprobs=[-0.1, -0.2],
+ completion_temperatures=[1.0, 1.0],
+ advantages=[0.0, 0.0, 1.0, 1.0],
+ env_name="test-env",
+ )
+
+ micro_batch = prepare_sample(sample, seq_len=8)
+ assert micro_batch.routed_experts is None
diff --git a/tests/unit/orchestrator/test_orchestrator_setup.py b/tests/unit/orchestrator/test_orchestrator_setup.py
index 2372b004fd..47147e6597 100644
--- a/tests/unit/orchestrator/test_orchestrator_setup.py
+++ b/tests/unit/orchestrator/test_orchestrator_setup.py
@@ -4,21 +4,21 @@
from renderers import Qwen3VLRendererConfig
-from prime_rl.orchestrator.utils import setup_student_inference_pool
+from prime_rl.orchestrator.utils import setup_policy_inference_pool
-def test_setup_student_inference_pool_uses_renderer_when_enabled():
+def test_setup_policy_inference_pool_uses_renderer_when_enabled():
async def run() -> None:
tokenizer = object()
renderer_settings = Qwen3VLRendererConfig()
config = SimpleNamespace(
- training_mode="rl",
- student=SimpleNamespace(
+ model=SimpleNamespace(
client=SimpleNamespace(base_url=["http://localhost:8000/v1"]),
- model=SimpleNamespace(name="student-model"),
+ name="policy-model",
),
renderer=renderer_settings,
pool_size=None,
+ any_policy_sourced=True,
)
renderer = object()
inference_pool = object()
@@ -30,7 +30,7 @@ async def run() -> None:
new=AsyncMock(return_value=inference_pool),
) as setup_pool_mock,
):
- returned_renderer, returned_pool = await setup_student_inference_pool(
+ returned_renderer, returned_pool = await setup_policy_inference_pool(
config=config,
tokenizer=tokenizer,
)
@@ -39,8 +39,8 @@ async def run() -> None:
assert returned_pool is inference_pool
create_renderer_mock.assert_called_once_with(tokenizer, renderer_settings)
setup_pool_mock.assert_awaited_once_with(
- config.student.client,
- model_name="student-model",
+ config.model.client,
+ model_name="policy-model",
train_client_type="renderer",
eval_client_type="openai_chat_completions",
renderer_config=renderer_settings,
@@ -50,18 +50,17 @@ async def run() -> None:
asyncio.run(run())
-def test_setup_student_inference_pool_defaults_to_mito():
+def test_setup_policy_inference_pool_defaults_to_mito():
"""No renderer -> plain MITO chat completions."""
async def run() -> None:
tokenizer = object()
config = SimpleNamespace(
- training_mode="rl",
renderer=None,
pool_size=None,
- student=SimpleNamespace(
+ model=SimpleNamespace(
client=SimpleNamespace(base_url=["http://localhost:8000/v1"]),
- model=SimpleNamespace(name="student-model"),
+ name="policy-model",
),
)
inference_pool = object()
@@ -73,7 +72,7 @@ async def run() -> None:
new=AsyncMock(return_value=inference_pool),
) as setup_pool_mock,
):
- renderer, returned_pool = await setup_student_inference_pool(
+ renderer, returned_pool = await setup_policy_inference_pool(
config=config,
tokenizer=tokenizer,
)
@@ -82,8 +81,53 @@ async def run() -> None:
assert returned_pool is inference_pool
create_renderer_mock.assert_not_called()
setup_pool_mock.assert_awaited_once_with(
- config.student.client,
- model_name="student-model",
+ config.model.client,
+ model_name="policy-model",
+ train_client_type="openai_chat_completions",
+ eval_client_type="openai_chat_completions",
+ )
+
+ asyncio.run(run())
+
+
+def test_setup_policy_inference_pool_keeps_renderer_without_policy_sampling():
+ """Frozen-sourced runs (e.g. sft) keep the renderer object for client-side
+ tokenization, but the policy pool serves plain chat completions — the
+ renderer-client sampling path is never wired."""
+
+ async def run() -> None:
+ tokenizer = object()
+ renderer_settings = Qwen3VLRendererConfig()
+ config = SimpleNamespace(
+ model=SimpleNamespace(
+ client=SimpleNamespace(base_url=["http://localhost:8000/v1"]),
+ name="policy-model",
+ ),
+ renderer=renderer_settings,
+ pool_size=None,
+ any_policy_sourced=False,
+ )
+ renderer = object()
+ inference_pool = object()
+
+ with (
+ patch("renderers.base.create_renderer", return_value=renderer) as create_renderer_mock,
+ patch(
+ "prime_rl.orchestrator.utils.setup_inference_pool",
+ new=AsyncMock(return_value=inference_pool),
+ ) as setup_pool_mock,
+ ):
+ returned_renderer, returned_pool = await setup_policy_inference_pool(
+ config=config,
+ tokenizer=tokenizer,
+ )
+
+ assert returned_renderer is renderer
+ assert returned_pool is inference_pool
+ create_renderer_mock.assert_called_once_with(tokenizer, renderer_settings)
+ setup_pool_mock.assert_awaited_once_with(
+ config.model.client,
+ model_name="policy-model",
train_client_type="openai_chat_completions",
eval_client_type="openai_chat_completions",
)
diff --git a/tests/unit/orchestrator/test_teacher_logprobs.py b/tests/unit/orchestrator/test_prefill_logprobs.py
similarity index 75%
rename from tests/unit/orchestrator/test_teacher_logprobs.py
rename to tests/unit/orchestrator/test_prefill_logprobs.py
index d63fdce792..0d9c9d0581 100644
--- a/tests/unit/orchestrator/test_teacher_logprobs.py
+++ b/tests/unit/orchestrator/test_prefill_logprobs.py
@@ -5,7 +5,6 @@
import verifiers as vf
from prime_rl.orchestrator import utils as orchestrator_utils
-from prime_rl.transport import TrainingSample
class _FakeOpenAIClient:
@@ -30,7 +29,7 @@ async def post(self, url, *, cast_to, body):
)
-def test_compute_teacher_logprobs_uses_inference_generate(monkeypatch):
+def test_compute_prefill_logprobs_uses_inference_generate(monkeypatch):
async def _run():
fake_client = _FakeOpenAIClient(
{
@@ -43,29 +42,19 @@ async def _run():
)
monkeypatch.setattr(orchestrator_utils, "setup_openai_client", lambda _: fake_client)
- sample = TrainingSample(
- prompt_ids=[1],
- prompt_mask=[True],
- completion_ids=[2, 3],
- completion_mask=[True, True],
- completion_logprobs=[-0.1, -0.2],
- completion_temperatures=[1.0, 1.0],
- env_name="test-env",
+ result = await orchestrator_utils.compute_prefill_logprobs(
+ vf.ClientConfig(),
+ model_name="ref-model",
+ token_ids=[1, 2, 3],
)
- result = await orchestrator_utils.compute_teacher_logprobs(
- clients=[vf.ClientConfig()],
- model_name="teacher-model",
- samples=[sample],
- )
-
- assert result == [[0.0, -0.7, -0.3]]
+ assert result == [0.0, -0.7, -0.3]
assert fake_client.calls == [
{
"url": "http://fake-host:8000/inference/v1/generate",
"cast_to": httpx.Response,
"body": {
- "model": "teacher-model",
+ "model": "ref-model",
"token_ids": [1, 2, 3],
"sampling_params": {
"max_tokens": 1,
diff --git a/tests/unit/test_configs.py b/tests/unit/test_configs.py
index 21d09baa29..4999f933d7 100644
--- a/tests/unit/test_configs.py
+++ b/tests/unit/test_configs.py
@@ -167,6 +167,38 @@ def test_removed_fused_lm_head_chunk_size_field_is_rejected():
TrainerModelConfig.model_validate({"fused_lm_head_chunk_size": "auto"})
+def test_env_advantage_shorthand_assembles_own_algorithm():
+ config = OrchestratorConfig.model_validate(
+ {
+ "renderer": {"name": "qwen3"}, # echo needs the renderer's role attribution
+ "algo": {"advantage": {"type": "echo"}},
+ "train": {"env": [{"id": "a", "advantage": {"type": "reward"}}, {"id": "b"}]},
+ }
+ )
+ env_a, env_b = config.train.env
+ # The shorthand makes env a assemble its own algorithm (default sampling +
+ # the given advantage); only env b inherits the top-level echo algorithm.
+ assert env_a.algo is not None and env_a.algo.advantage.type == "reward"
+ assert env_b.algo is not None and env_b.algo.advantage.type == "echo"
+
+ # The shorthand is write-only sugar: resolved configs dump without it and round-trip.
+ dumped = config.model_dump(exclude_none=True)
+ assert "advantage" not in dumped and "advantage" not in dumped["train"]["env"][0]
+ reloaded = OrchestratorConfig.model_validate(dumped)
+ assert reloaded.train.env[0].algo is not None and reloaded.train.env[0].algo.advantage.type == "reward"
+
+
+def test_advantage_shorthand_conflicts_with_explicit_algo_advantage():
+ with pytest.raises(ValidationError, match="Set one"):
+ OrchestratorConfig.model_validate(
+ {
+ "renderer": None,
+ "advantage": {"type": "reward"},
+ "algo": {"advantage": {"type": "grpo"}},
+ }
+ )
+
+
def test_trainer_enable_token_export_cli_flag():
assert not cli(TrainerConfig, args=[]).enable_token_export
assert cli(TrainerConfig, args=["--enable-token-export"]).enable_token_export
@@ -176,14 +208,12 @@ def test_orchestrator_vlm_requires_renderer():
with pytest.raises(ValidationError, match="orchestrator.renderer must be set when model.vlm is set"):
OrchestratorConfig.model_validate(
{
- "student": {
- "model": {
- "name": "Qwen/Qwen3-VL-4B-Instruct",
- "vlm": {
- "vision_encoder_attr": "model.visual",
- "language_model_attr": "model.language_model",
- },
- }
+ "model": {
+ "name": "Qwen/Qwen3-VL-4B-Instruct",
+ "vlm": {
+ "vision_encoder_attr": "model.visual",
+ "language_model_attr": "model.language_model",
+ },
},
"renderer": None,
}
@@ -191,14 +221,12 @@ def test_orchestrator_vlm_requires_renderer():
config = OrchestratorConfig.model_validate(
{
- "student": {
- "model": {
- "name": "Qwen/Qwen3-VL-4B-Instruct",
- "vlm": {
- "vision_encoder_attr": "model.visual",
- "language_model_attr": "model.language_model",
- },
- }
+ "model": {
+ "name": "Qwen/Qwen3-VL-4B-Instruct",
+ "vlm": {
+ "vision_encoder_attr": "model.visual",
+ "language_model_attr": "model.language_model",
+ },
},
}
)
@@ -222,7 +250,7 @@ def test_shared_model_name_propagates_to_subconfigs():
}
)
assert config.trainer.model.name == model_name
- assert config.orchestrator.student.model.name == model_name
+ assert config.orchestrator.model.name == model_name
assert config.inference is not None and config.inference.model.name == model_name
assert config.trainer.tokenizer.name == model_name
assert config.orchestrator.tokenizer.name == model_name
@@ -277,7 +305,7 @@ def test_explicit_subconfig_tokenizer_name_survives_shared_model_propagation():
This is the case that the old RL-level ``auto_setup_tokenizer`` fix-up got
wrong: it unconditionally re-derived ``orchestrator.tokenizer.name`` from
- ``orchestrator.student.model.name`` after propagation, silently overriding
+ ``orchestrator.model.name`` after propagation, silently overriding
the user's explicit value. The ``mode="before"`` ``auto_setup_shared_configs``
propagator fixes this because it propagates the model name into the raw
dict before sub-configs are built, so ``OrchestratorConfig``'s own
@@ -297,7 +325,7 @@ def test_explicit_subconfig_tokenizer_name_survives_shared_model_propagation():
)
# Shared model.name reached every sub-config that didn't override it.
assert config.trainer.model.name == "M"
- assert config.orchestrator.student.model.name == "M"
+ assert config.orchestrator.model.name == "M"
# Trainer didn't specify a tokenizer, so it falls back to the propagated model name.
assert config.trainer.tokenizer.name == "M"
# Orchestrator's explicit tokenizer name survived.
diff --git a/tests/unit/train/models/test_nemotron_h_kl.py b/tests/unit/train/models/test_nemotron_h_kl.py
index 09c0403359..282134b3d0 100644
--- a/tests/unit/train/models/test_nemotron_h_kl.py
+++ b/tests/unit/train/models/test_nemotron_h_kl.py
@@ -99,7 +99,7 @@ def test_kl_zero_when_identical():
inputs = LossInputs(
trainer_logprobs=logprobs[i],
inference_logprobs=logprobs[i],
- teacher_logprobs=None,
+ ref_logprobs=None,
advantages=advantages,
loss_mask=loss_mask,
)
@@ -133,7 +133,7 @@ def test_kl_positive_after_perturbation():
inputs = LossInputs(
trainer_logprobs=policy_logprobs[i],
inference_logprobs=ref_logprobs[i],
- teacher_logprobs=None,
+ ref_logprobs=None,
advantages=advantages,
loss_mask=loss_mask,
)
diff --git a/tests/unit/train/rl/test_loss.py b/tests/unit/train/rl/test_loss.py
index 1585dac7bd..84d1c7d4ae 100644
--- a/tests/unit/train/rl/test_loss.py
+++ b/tests/unit/train/rl/test_loss.py
@@ -2,7 +2,7 @@
import torch
from prime_rl.configs.trainer import CustomLossConfig, DefaultLossConfig
-from prime_rl.trainer.rl.loss import LossInputs, LossOutputs, compute_entropy, compute_loss, setup_loss_fns
+from prime_rl.trainer.rl.loss import LossInputs, LossOutputs, compute_entropy, compute_loss, setup_rl_loss_fn
pytestmark = [pytest.mark.gpu]
@@ -10,19 +10,24 @@
def test_grpo_loss():
trainer_logprobs = [torch.randn(50, dtype=torch.float32).cuda(), torch.randn(30, dtype=torch.float32).cuda()]
inference_logprobs = [torch.randn(50, dtype=torch.float32).cuda(), torch.randn(30, dtype=torch.float32).cuda()]
- teacher_logprobs = [torch.randn(50, dtype=torch.float32).cuda(), torch.randn(30, dtype=torch.float32).cuda()]
+ ref_logprobs = [torch.randn(50, dtype=torch.float32).cuda(), torch.randn(30, dtype=torch.float32).cuda()]
advantages = [torch.randn(50).cuda(), torch.randn(30).cuda()]
loss_mask = [torch.ones(50, dtype=torch.bool).cuda(), torch.ones(30, dtype=torch.bool).cuda()]
- loss_fns = setup_loss_fns(DefaultLossConfig(dppo_mask_high=10.0))
+ rl_loss_fn = setup_rl_loss_fn(DefaultLossConfig(dppo_mask_high=10.0))
loss, _ = compute_loss(
trainer_logprobs,
inference_logprobs,
- teacher_logprobs,
+ ref_logprobs,
advantages,
loss_mask=loss_mask,
- loss_fns=loss_fns,
- loss_scale=1.0,
+ rl_weights=None,
+ ce_weights=None,
+ ref_kl_weights=None,
+ rl_loss_fn=rl_loss_fn,
+ rl_scale=1,
+ ce_scale=1,
+ ref_kl_scale=1,
)
assert loss.shape == ()
@@ -30,19 +35,24 @@ def test_grpo_loss():
def test_gspo_loss():
trainer_logprobs = [torch.randn(40, dtype=torch.float32).cuda(), torch.randn(60, dtype=torch.float32).cuda()]
inference_logprobs = [torch.randn(40, dtype=torch.float32).cuda(), torch.randn(60, dtype=torch.float32).cuda()]
- teacher_logprobs = [torch.randn(40, dtype=torch.float32).cuda(), torch.randn(60, dtype=torch.float32).cuda()]
+ ref_logprobs = [torch.randn(40, dtype=torch.float32).cuda(), torch.randn(60, dtype=torch.float32).cuda()]
advantages = [torch.randn(40).cuda(), torch.randn(60).cuda()]
loss_mask = [torch.ones(40, dtype=torch.bool).cuda(), torch.ones(60, dtype=torch.bool).cuda()]
- loss_fns = setup_loss_fns(DefaultLossConfig(dppo_mask_high=10.0))
+ rl_loss_fn = setup_rl_loss_fn(DefaultLossConfig(dppo_mask_high=10.0))
loss, _ = compute_loss(
trainer_logprobs,
inference_logprobs,
- teacher_logprobs,
+ ref_logprobs,
advantages,
loss_mask=loss_mask,
- loss_fns=loss_fns,
- loss_scale=1.0,
+ rl_weights=None,
+ ce_weights=None,
+ ref_kl_weights=None,
+ rl_loss_fn=rl_loss_fn,
+ rl_scale=1,
+ ce_scale=1,
+ ref_kl_scale=1,
)
assert loss.shape == ()
@@ -53,72 +63,215 @@ def test_entropy_loss():
assert entropy.shape == (10, 10)
-def test_setup_loss_fns_with_custom_config():
- """Test setup_loss_fns with CustomLossConfig importing a custom loss."""
+def test_setup_rl_loss_fn_with_custom_config():
+ """Test setup_rl_loss_fn with CustomLossConfig importing a custom loss."""
loss_config = CustomLossConfig(
import_path="tests.unit.train.rl.test_loss._dummy_custom_loss",
kwargs={"multiplier": 2.0},
)
- loss_fns = setup_loss_fns(loss_config)
+ rl_loss_fn = setup_rl_loss_fn(loss_config)
inputs = LossInputs(
trainer_logprobs=torch.randn(50, dtype=torch.float32).cuda(),
inference_logprobs=torch.randn(50, dtype=torch.float32).cuda(),
- teacher_logprobs=None,
+ ref_logprobs=None,
advantages=torch.randn(50).cuda(),
loss_mask=torch.ones(50, dtype=torch.bool).cuda(),
)
- result = loss_fns["rl"](inputs)
+ result = rl_loss_fn(inputs)
assert isinstance(result, LossOutputs)
assert result.loss.shape == ()
assert "custom_metric" in result.metrics
-def test_sft_loss_matches_masked_nll():
+def test_ce_component_matches_masked_nll():
trainer_logprobs = [torch.tensor([-0.1, -0.5, -0.2], dtype=torch.float32).cuda()]
inference_logprobs = [torch.zeros(3, dtype=torch.float32).cuda()]
advantages = [torch.zeros(3, dtype=torch.float32).cuda()]
loss_mask = [torch.tensor([True, False, True], dtype=torch.bool).cuda()]
+ rl_weights = [torch.zeros(3, dtype=torch.float32).cuda()]
+ ce_weights = [torch.tensor([1.0, 0.0, 1.0], dtype=torch.float32).cuda()]
- loss_fns = setup_loss_fns(DefaultLossConfig())
+ rl_loss_fn = setup_rl_loss_fn(DefaultLossConfig())
loss, metrics = compute_loss(
trainer_logprobs=trainer_logprobs,
inference_logprobs=inference_logprobs,
- teacher_logprobs=None,
+ ref_logprobs=None,
advantages=advantages,
loss_mask=loss_mask,
- loss_fns=loss_fns,
- loss_scale=2,
- training_mode="sft",
+ rl_weights=rl_weights,
+ ce_weights=ce_weights,
+ ref_kl_weights=None,
+ rl_loss_fn=rl_loss_fn,
+ rl_scale=1,
+ ce_scale=2,
+ ref_kl_scale=1,
)
- # loss = -sum(masked logprobs) / loss_scale = -(-0.1 - 0.2) / 2 = 0.15
+ # loss = -sum(member logprobs) / ce_scale = -(-0.1 - 0.2) / 2 = 0.15
assert torch.isclose(loss, torch.tensor(0.15, device=loss.device), atol=1e-6)
assert "nll" in metrics
+ assert "mismatch_kl" not in metrics
-def test_sft_loss_override_uses_masked_nll_with_default_loss_config():
+def test_ce_component_applies_weights():
+ """ECHO-style observation training: the ce weight stream scales the NLL per token."""
trainer_logprobs = [torch.tensor([-0.1, -0.5, -0.2], dtype=torch.float32).cuda()]
inference_logprobs = [torch.zeros(3, dtype=torch.float32).cuda()]
- advantages = [torch.ones(3, dtype=torch.float32).cuda()]
+ advantages = [torch.zeros(3, dtype=torch.float32).cuda()]
loss_mask = [torch.tensor([True, False, True], dtype=torch.bool).cuda()]
+ rl_weights = [torch.zeros(3, dtype=torch.float32).cuda()]
+ ce_weights = [torch.tensor([0.1, 0.0, 0.1], dtype=torch.float32).cuda()]
+
+ rl_loss_fn = setup_rl_loss_fn(DefaultLossConfig())
+ loss, _ = compute_loss(
+ trainer_logprobs=trainer_logprobs,
+ inference_logprobs=inference_logprobs,
+ ref_logprobs=None,
+ advantages=advantages,
+ loss_mask=loss_mask,
+ rl_weights=rl_weights,
+ ce_weights=ce_weights,
+ ref_kl_weights=None,
+ rl_loss_fn=rl_loss_fn,
+ rl_scale=1,
+ ce_scale=1,
+ ref_kl_scale=1,
+ )
+
+ # loss = 0.1 * (0.1 + 0.2) = 0.03
+ assert torch.isclose(loss, torch.tensor(0.03, device=loss.device), atol=1e-6)
+
- loss_fns = setup_loss_fns(DefaultLossConfig())
+def test_explicit_rl_weights_match_absent_stream():
+ """An explicit all-ones rl stream must equal the rl_weights=None hot path."""
+ torch.manual_seed(0)
+ trainer_logprobs = [torch.randn(50, dtype=torch.float32).cuda()]
+ inference_logprobs = [torch.randn(50, dtype=torch.float32).cuda()]
+ advantages = [torch.randn(50).cuda()]
+ loss_mask = [torch.rand(50).cuda() > 0.3]
+
+ rl_loss_fn = setup_rl_loss_fn(DefaultLossConfig())
+ kwargs = dict(
+ trainer_logprobs=trainer_logprobs,
+ inference_logprobs=inference_logprobs,
+ ref_logprobs=None,
+ advantages=advantages,
+ loss_mask=loss_mask,
+ ce_weights=None,
+ ref_kl_weights=None,
+ rl_loss_fn=rl_loss_fn,
+ rl_scale=1,
+ ce_scale=1,
+ ref_kl_scale=1,
+ )
+ loss_absent, _ = compute_loss(rl_weights=None, **kwargs)
+ loss_explicit, _ = compute_loss(rl_weights=[torch.ones(50, dtype=torch.float32).cuda()], **kwargs)
+
+ assert torch.equal(loss_absent, loss_explicit)
+
+
+def test_disjoint_components_in_one_sequence():
+ """ECHO/OPD-shaped sequence: rl, ce, and ref_kl on disjoint token sets."""
+ n = 12
+ torch.manual_seed(1)
+ trainer_logprobs = [torch.randn(n, dtype=torch.float32).cuda()]
+ inference_logprobs = [torch.randn(n, dtype=torch.float32).cuda()]
+ ref_logprobs = [torch.randn(n, dtype=torch.float32).cuda()]
+ advantages = [torch.randn(n).cuda()]
+ loss_mask = [torch.ones(n, dtype=torch.bool).cuda()]
+ rl_weights = torch.zeros(n, dtype=torch.float32)
+ rl_weights[:4] = 1.0
+ ce_weights = torch.zeros(n, dtype=torch.float32)
+ ce_weights[4:8] = 1.0
+ ref_kl_weights = torch.zeros(n, dtype=torch.float32)
+ ref_kl_weights[8:] = 1.0
+
+ rl_loss_fn = setup_rl_loss_fn(DefaultLossConfig(dppo_mask_high=10.0))
loss, metrics = compute_loss(
trainer_logprobs=trainer_logprobs,
inference_logprobs=inference_logprobs,
- teacher_logprobs=None,
+ ref_logprobs=ref_logprobs,
advantages=advantages,
loss_mask=loss_mask,
- loss_fns=loss_fns,
- loss_scale=2,
- training_mode="sft",
+ rl_weights=[rl_weights.cuda()],
+ ce_weights=[ce_weights.cuda()],
+ ref_kl_weights=[ref_kl_weights.cuda()],
+ rl_loss_fn=rl_loss_fn,
+ rl_scale=1,
+ ce_scale=1,
+ ref_kl_scale=1,
)
- assert torch.isclose(loss, torch.tensor(0.15, device=loss.device), atol=1e-6)
+ assert loss.shape == ()
assert "nll" in metrics
- assert "mismatch_kl" not in metrics
+ assert "ref_kl" in metrics
+ assert "is_masked" in metrics
+
+
+def test_empty_components_keep_backward_valid():
+ """A fully truncated distillation sample (stamped streams survive truncation
+ as all-zero prefixes) must train as a zero-gradient no-op, not crash backward."""
+ trainer_logprobs = [torch.randn(6, dtype=torch.float32, device="cuda", requires_grad=True)]
+ inference_logprobs = [torch.zeros(6, dtype=torch.float32).cuda()]
+ advantages = [torch.zeros(6, dtype=torch.float32).cuda()]
+ loss_mask = [torch.zeros(6, dtype=torch.bool).cuda()]
+ rl_weights = [torch.zeros(6, dtype=torch.float32).cuda()]
+ ce_weights = [torch.zeros(6, dtype=torch.float32).cuda()]
+
+ rl_loss_fn = setup_rl_loss_fn(DefaultLossConfig())
+ loss, _ = compute_loss(
+ trainer_logprobs=trainer_logprobs,
+ inference_logprobs=inference_logprobs,
+ ref_logprobs=None,
+ advantages=advantages,
+ loss_mask=loss_mask,
+ rl_weights=rl_weights,
+ ce_weights=ce_weights,
+ ref_kl_weights=None,
+ rl_loss_fn=rl_loss_fn,
+ rl_scale=1,
+ ce_scale=1,
+ ref_kl_scale=1,
+ )
+
+ assert torch.equal(loss, torch.zeros_like(loss))
+ loss.backward()
+ assert trainer_logprobs[0].grad is not None
+ assert torch.equal(trainer_logprobs[0].grad, torch.zeros_like(trainer_logprobs[0].grad))
+
+
+def test_overlapping_components_sum():
+ """Components may overlap on the same token (e.g. RL + a CE behavior-cloning
+ regularizer): the total is the sum of each component computed alone, each
+ over its own normalization."""
+ n = 8
+ torch.manual_seed(2)
+ trainer_logprobs = [torch.randn(n, dtype=torch.float32).cuda()]
+ inference_logprobs = [torch.randn(n, dtype=torch.float32).cuda()]
+ advantages = [torch.randn(n).cuda()]
+ loss_mask = [torch.ones(n, dtype=torch.bool).cuda()]
+ ce_weights = [torch.full((n,), 0.5, dtype=torch.float32).cuda()]
+
+ rl_loss_fn = setup_rl_loss_fn(DefaultLossConfig(dppo_mask_high=10.0))
+ kwargs = dict(
+ trainer_logprobs=trainer_logprobs,
+ inference_logprobs=inference_logprobs,
+ ref_logprobs=None,
+ advantages=advantages,
+ loss_mask=loss_mask,
+ ref_kl_weights=None,
+ rl_loss_fn=rl_loss_fn,
+ rl_scale=4,
+ ce_scale=8,
+ ref_kl_scale=1,
+ )
+ rl_only, _ = compute_loss(rl_weights=None, ce_weights=None, **kwargs)
+ ce_only, _ = compute_loss(rl_weights=[torch.zeros(n, dtype=torch.float32).cuda()], ce_weights=ce_weights, **kwargs)
+ both, _ = compute_loss(rl_weights=None, ce_weights=ce_weights, **kwargs)
+
+ assert torch.isclose(both, rl_only + ce_only, atol=1e-6)
def _dummy_custom_loss(inputs: LossInputs, multiplier: float = 1.0) -> LossOutputs:
diff --git a/tests/unit/train/rl/test_packer.py b/tests/unit/train/rl/test_packer.py
index 1cf6beac0d..0ec2371b24 100644
--- a/tests/unit/train/rl/test_packer.py
+++ b/tests/unit/train/rl/test_packer.py
@@ -49,6 +49,7 @@ def make_training_sample() -> TrainingSample:
completion_mask=[True],
completion_logprobs=[-0.1],
completion_temperatures=[1.0],
+ advantages=[0.0, 1.0],
env_name="test-env",
)
diff --git a/tests/unit/train/test_runs.py b/tests/unit/train/test_runs.py
index 6bb6623c06..81c3c65ddc 100644
--- a/tests/unit/train/test_runs.py
+++ b/tests/unit/train/test_runs.py
@@ -217,7 +217,7 @@ def test_config_loading(tmp_path: Path) -> None:
# Access config as OrchestratorConfig object
config = multi_run_manager.config[run_idx]
- assert config.student.model.name == "test-model"
+ assert config.model.name == "test-model"
assert config.batch_size == 32
assert config.max_steps == 1000
diff --git a/tests/utils.py b/tests/utils.py
index dbad6c55ad..eee659e22f 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -107,18 +107,21 @@ def check_loss_goes_down(lines: list[str]):
return check_number_goes_up_or_down(lines, go_up=False, pattern=r"Loss:?\s+(\d+\.\d{4})")
-def check_eval_avg_goes_up(lines: list[str], env_name: str):
- """Assert that the last `Evaluated {env_name} (Step N) | ... | Reward X.XXXX`
- line reports a higher score than the first one. Use for smoke tests with
- `interval = 1` evals."""
+def check_final_eval_reward_above(lines: list[str], env_name: str, min_threshold: float):
+ """Assert the LAST `Evaluated {env_name} (Step N) | ... | Reward X.XXXX`
+ line reports a reward above ``min_threshold``.
+
+ Robust for short distill smokes: until the policy converges, eval reward is
+ truncation-dominated noise, so an endpoint "did it go up?" compare is a coin
+ flip (e.g. distillation here doesn't surface until ~step 13). A threshold
+ the converged plateau clears — but the early noise floor doesn't —
+ validates that training actually worked. Pick one between the two."""
pattern = rf"Evaluated {re.escape(env_name)} .*Reward:?\s+(\d+\.\d{{4}})"
eval_lines = [line for line in lines if "SUCCESS" in line and re.search(pattern, line)]
assert len(eval_lines) >= 2, f"Need at least 2 eval lines for {env_name!r}, found {len(eval_lines)}"
- start = float(re.search(pattern, eval_lines[0]).group(1))
- end = float(re.search(pattern, eval_lines[-1]).group(1))
- assert end > start, (
- f"Eval avg for {env_name!r} did not go up: first={start} last={end}\n"
- f"first line: {eval_lines[0]}\nlast line: {eval_lines[-1]}"
+ final = float(re.search(pattern, eval_lines[-1]).group(1))
+ assert final >= min_threshold, (
+ f"Final eval reward for {env_name!r} below threshold: {final} < {min_threshold}\nlast line: {eval_lines[-1]}"
)