diff --git a/README.md b/README.md
index 19c614da5..075fa9ca4 100644
--- a/README.md
+++ b/README.md
@@ -180,15 +180,19 @@ Visit our [documentation](https://verl-omni.readthedocs.io/en/latest/index.html)
Planned |
- | Qwen3-Omni-Thinker |
- Omni-modality |
- Text / Image / Video / Audio |
+ Qwen3-Omni-Thinker |
+ Omni-modality |
+ Text / Image / Video / Audio |
DPO |
✅ |
| GSPO |
✅ |
+
+
+ | DAPO |
+ WIP |
| Qwen3-TTS |
diff --git a/docs/examples/dapo_trainer.md b/docs/examples/dapo_trainer.md
new file mode 120000
index 000000000..7e662ccd6
--- /dev/null
+++ b/docs/examples/dapo_trainer.md
@@ -0,0 +1 @@
+../../examples/dapo_trainer/README.md
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
index 7541bbc27..ddbf995d7 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -72,6 +72,7 @@ algo/performance.md
examples/flowgrpo_trainer.md
examples/flowdppo_trainer.md
examples/dpo_trainer.md
+examples/dapo_trainer.md
examples/dancegrpo_trainer.md
examples/diffusionnft_trainer.md
examples/grpoguard_trainer.md
diff --git a/examples/dapo_trainer/README.md b/examples/dapo_trainer/README.md
new file mode 100644
index 000000000..08184ceee
--- /dev/null
+++ b/examples/dapo_trainer/README.md
@@ -0,0 +1,117 @@
+# Qwen3-Omni Thinker DAPO Trainer
+
+Last updated: 09/04/2026
+
+This example provides the first Qwen3-Omni Thinker DAPO milestone on the V1
+omni trainer: GPU LoRA training on multimodal AVQA with clip-higher,
+token-level policy gradient, GRPO advantages, and the AVQA choice reward.
+
+The Phase 1 launcher intentionally disables dynamic sampling:
+
+```text
+algorithm.filter_groups.enable=false
+```
+
+It also does not enable the overlong reward buffer. Those components are kept
+out of this baseline so the token-level DAPO policy path can be validated
+independently. In this Phase 1 recipe, DAPO refers to vanilla token-level
+policy loss with asymmetric clipping, GRPO advantages, and no KL penalty. The
+registered naive reward manager calls the AVQA `choice_reward`; the reward
+manager name alone does not select the optimization algorithm.
+
+**Phase 2 (#446): overlong reward buffer.** Overlong shaping is wired through
+`reward.reward_kwargs` and only applies with `reward.reward_manager.name=dapo`
+(`source=register`) — it is a no-op under the `naive` manager this example
+uses. See `tests/special_e2e/run_dapo_qwen3_omni_thinker_lora_v1_smoke.sh` for
+a working `name=dapo` recipe with overlong shaping enabled, and
+`tests/utils/test_dapo_overlong_reward_on_cpu.py` for the reward-shape
+contract: `reward.reward_kwargs.overlong_buffer_cfg.{enable,len,penalty_factor,log}`
+and `reward.reward_kwargs.max_resp_len`.
+
+**Phase 3 (#446): dynamic sampling.** The upstream V1 `PPOTrainer` replay
+buffer already implements group filtering — this is config-only, no new
+trainer code. `run_qwen3_omni_thinker_dapo_dynamic_sampling_lora_v1.sh` sets
+`algorithm.filter_groups.enable=true` with `metric=acc` (the naive/DAPO
+reward managers always populate `reward_extra_info["acc"]`, see
+`verl.experimental.reward_loop.reward_manager.naive`), so the trainer drops
+uniform-reward groups (all-correct or all-wrong) and keeps generating until
+`data.train_batch_size` qualified prompts are collected, bounded by
+`algorithm.filter_groups.max_inflight_gen_batches`. Group filtering requires a
+streaming reward path (`reward.reward_model.enable=false`, the default), so
+this recipe uses the `dapo` reward manager rather than Phase 1's `naive` one.
+
+**Phase 4 (#446): a second modality.**
+`run_qwen3_omni_thinker_dapo_lora_mmk12_v1.sh` applies the same Phase 1-3 DAPO
+contract (token-level clip-higher, dynamic sampling, overlong shaping) to
+MMK12 math reasoning, reusing the existing `mmk12_reward.py` scorer and data
+pipeline from `examples/gspo_trainer/data_process/mmk12.py`. It is not a new
+adapter — only the dataset, reward scorer, and `reward_kwargs.overlong_buffer_cfg.len`
+(sized for MMK12's longer 12288-token responses) differ from the AVQA
+recipe.
+
+**Phase 5 (#446): support matrix.** The top-level [README](../../README.md)
+now lists Qwen3-Omni-Thinker × DAPO as WIP (Phase 1-4 recipes above are not
+yet GPU-validated by a maintainer). **`reward.reward_manager.name=dapo` alone
+does not select the DAPO algorithm** — it only selects the DAPO reward
+manager (overlong buffer, math parsing). The optimization algorithm is
+selected by `actor_rollout_ref.actor.policy_loss.loss_mode` and
+`algorithm.adv_estimator`; running `loss_mode=gspo` with
+`reward_manager.name=dapo` still trains GSPO. See the
+[RFC](https://github.com/verl-project/verl-omni/issues/446) for the full
+GSPO-vs-DAPO knob table.
+
+## Recipes
+
+| Launcher | Dataset | Dynamic sampling | Overlong buffer |
+| --- | --- | --- | --- |
+| `run_qwen3_omni_thinker_dapo_lora_v1.sh` | AVQA | ✗ (Phase 1 baseline) | ✗ |
+| `run_qwen3_omni_thinker_dapo_dynamic_sampling_lora_v1.sh` | AVQA | ✅ | ✅ |
+| `run_qwen3_omni_thinker_dapo_lora_mmk12_v1.sh` | MMK12 | ✅ | ✅ |
+
+## Run
+
+Download and extract the AVQA-R1-6K data, then convert it from the repository
+root:
+
+```bash
+python examples/gspo_trainer/data_process/avqa.py \
+ --input_dir /path/to/raw/AVQA_R1 \
+ --output_dir ~/data/avqa_r1_6k
+```
+
+The converted parquet stores absolute image and audio paths. Every Ray worker
+must mount the converted dataset and its media files at the same absolute path
+used during conversion.
+
+Install the audio and multimodal processing dependencies on every Ray worker,
+then launch:
+
+```bash
+pip install -e ".[audio]"
+pip install qwen-vl-utils
+```
+
+```bash
+bash examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_v1.sh
+```
+
+The default model is `~/models/Qwen/Qwen3-Omni-30B-A3B-Instruct`. Override the
+model, data, or any Hydra setting without editing the script:
+
+```bash
+MODEL_PATH=/path/to/Qwen3-Omni-30B-A3B-Instruct \
+TRAIN_FILE=/path/to/train.parquet \
+VAL_FILE=/path/to/validation.parquet \
+bash examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_v1.sh \
+ trainer.total_training_steps=2
+```
+
+Validation runs once before training and every 10 steps by default. It uses
+greedy decoding (`n=1`, `do_sample=false`, `temperature=0`, `top_p=1`,
+`top_k=-1`) over the full validation split. Plot
+`val-core/avqa_r1_6k/reward/mean@1` against the trainer step for the directly
+comparable in-trainer validation curve.
+
+Only the Thinker LoRA adapters are trained. Talker, code2wav, code predictor,
+visual projection, and audio-tower modules are excluded, and the vision tower
+is frozen, matching the existing GSPO V1 baseline.
diff --git a/examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_dynamic_sampling_lora_v1.sh b/examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_dynamic_sampling_lora_v1.sh
new file mode 100755
index 000000000..5001d0bf0
--- /dev/null
+++ b/examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_dynamic_sampling_lora_v1.sh
@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+# Qwen3-Omni Thinker DAPO + LoRA training on AVQA with dynamic sampling.
+#
+# Phase 3 (#446): enables algorithm.filter_groups so the V1 replay buffer
+# drops uniform-reward groups (all-correct / all-wrong) and keeps generating
+# until data.train_batch_size qualified prompts are collected, up to
+# algorithm.filter_groups.max_inflight_gen_batches concurrent generation
+# batches. This requires a streaming (non-colocated) reward path, so the
+# reward manager below is `dapo` via the registered reward loop, matching the
+# Phase 2 overlong-buffer smoke rather than Phase 1's `naive` manager.
+#
+# Data preparation (run once):
+# python examples/gspo_trainer/data_process/avqa.py \
+# --input_dir \
+# --output_dir ~/data/avqa_r1_6k
+#
+# Runtime dependencies (all Ray worker nodes):
+# pip install -e ".[audio]"
+# pip install qwen-vl-utils
+
+set -xeuo pipefail
+
+# Make verl_omni available to Ray workers.
+export VERL_USE_EXTERNAL_MODULES=verl_omni
+
+MODEL_PATH=${MODEL_PATH:-"$HOME/models/Qwen/Qwen3-Omni-30B-A3B-Instruct"}
+TRAIN_FILE=${TRAIN_FILE:-"$HOME/data/avqa_r1_6k/train.parquet"}
+VAL_FILE=${VAL_FILE:-"$HOME/data/avqa_r1_6k/validation.parquet"}
+
+python3 -m verl_omni.trainer.main_omni \
+ data.train_files="${TRAIN_FILE}" \
+ data.val_files="${VAL_FILE}" \
+ data.train_batch_size=128 \
+ data.max_prompt_length=4096 \
+ data.max_response_length=12288 \
+ data.shuffle=true \
+ data.seed=42 \
+ data.val_max_samples=-1 \
+ data.validation_shuffle=false \
+ data.truncation='error' \
+ data.filter_overlong_prompts=true \
+ data.custom_cls.path=pkg://verl_omni.utils.dataset.omni_rl_datasets \
+ data.custom_cls.name=QwenOmniRLHFDataset \
+ +data.mm_processor_kwargs.sampling_rate=16000 \
+ actor_rollout_ref.model.path="${MODEL_PATH}" \
+ actor_rollout_ref.model.lora_rank=32 \
+ actor_rollout_ref.model.lora_alpha=64 \
+ actor_rollout_ref.model.lora_dtype=float32 \
+ actor_rollout_ref.model.lora.merge=true \
+ actor_rollout_ref.model.enable_gradient_checkpointing=true \
+ actor_rollout_ref.model.use_remove_padding=true \
+ actor_rollout_ref.model.exclude_modules=".*talker.*|.*code2wav.*|.*code_predictor.*|.*visual.*|.*audio_tower.*" \
+ actor_rollout_ref.model.target_modules="['q_proj','k_proj','v_proj','o_proj']" \
+ actor_rollout_ref.actor.freeze_vision_tower=true \
+ actor_rollout_ref.actor.strategy=fsdp2 \
+ actor_rollout_ref.actor.optim.lr=3e-6 \
+ actor_rollout_ref.actor.optim.weight_decay=0.01 \
+ actor_rollout_ref.actor.optim.clip_grad=1.0 \
+ actor_rollout_ref.actor.ppo_mini_batch_size=16 \
+ actor_rollout_ref.actor.use_dynamic_bsz=true \
+ actor_rollout_ref.actor.ppo_max_token_len_per_gpu=20480 \
+ actor_rollout_ref.actor.use_kl_loss=false \
+ actor_rollout_ref.actor.entropy_coeff=0 \
+ actor_rollout_ref.actor.policy_loss.loss_mode=vanilla \
+ actor_rollout_ref.actor.clip_ratio_low=0.2 \
+ actor_rollout_ref.actor.clip_ratio_high=0.28 \
+ actor_rollout_ref.actor.clip_ratio_c=10.0 \
+ actor_rollout_ref.actor.loss_agg_mode=token-mean \
+ actor_rollout_ref.actor.fsdp_config.model_dtype=bfloat16 \
+ actor_rollout_ref.actor.fsdp_config.param_offload=true \
+ actor_rollout_ref.actor.fsdp_config.optimizer_offload=true \
+ actor_rollout_ref.rollout.name=vllm_omni \
+ actor_rollout_ref.rollout.n=16 \
+ actor_rollout_ref.rollout.tensor_model_parallel_size=2 \
+ actor_rollout_ref.rollout.gpu_memory_utilization=0.7 \
+ actor_rollout_ref.rollout.load_format=safetensors \
+ actor_rollout_ref.rollout.prompt_length=4160 \
+ actor_rollout_ref.rollout.log_prob_use_dynamic_bsz=true \
+ actor_rollout_ref.rollout.log_prob_max_token_len_per_gpu=20480 \
+ actor_rollout_ref.rollout.enable_prefix_caching=false \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.output_mode="ar" \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.pipeline_name="qwen3_omni_moe" \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.max_num_seqs=256 \
+ actor_rollout_ref.rollout.cudagraph_capture_sizes=[1,2,4,8,16,32,64,128,256] \
+ actor_rollout_ref.rollout.val_kwargs.n=1 \
+ actor_rollout_ref.rollout.val_kwargs.do_sample=false \
+ actor_rollout_ref.rollout.val_kwargs.temperature=0 \
+ actor_rollout_ref.rollout.val_kwargs.top_p=1.0 \
+ actor_rollout_ref.rollout.val_kwargs.top_k=-1 \
+ actor_rollout_ref.ref.log_prob_use_dynamic_bsz=true \
+ actor_rollout_ref.ref.log_prob_max_token_len_per_gpu=20480 \
+ actor_rollout_ref.ref.fsdp_config.param_offload=true \
+ actor_rollout_ref.ref.fsdp_config.model_dtype=bfloat16 \
+ algorithm.trainer_type=policy_gradient \
+ algorithm.sample_source=online \
+ algorithm.adv_estimator=grpo \
+ algorithm.use_kl_in_reward=false \
+ algorithm.filter_groups.enable=true \
+ algorithm.filter_groups.metric=acc \
+ algorithm.filter_groups.max_inflight_gen_batches=2 \
+ reward.reward_model.enable=false \
+ reward.reward_manager.source=register \
+ reward.reward_manager.name=dapo \
+ reward.custom_reward_function.path=verl_omni/utils/reward_score/choice_reward.py \
+ reward.custom_reward_function.name=compute_score \
+ trainer.val_before_train=true \
+ trainer.balance_batch=true \
+ trainer.critic_warmup=0 \
+ trainer.logger='["console","wandb"]' \
+ trainer.project_name=dapo \
+ trainer.experiment_name=qwen3_omni_thinker_lora_avqa_dynamic_sampling \
+ trainer.n_gpus_per_node=4 \
+ trainer.nnodes=1 \
+ trainer.save_freq=50 \
+ trainer.test_freq=10 \
+ trainer.total_epochs=10 \
+ "$@"
diff --git a/examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_mmk12_v1.sh b/examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_mmk12_v1.sh
new file mode 100755
index 000000000..484a8e4ef
--- /dev/null
+++ b/examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_mmk12_v1.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# Qwen3-Omni Thinker DAPO + LoRA training on MMK12 with dynamic sampling.
+#
+# Phase 4 (#446): the AVQA DAPO recipes (Phase 1/2/3) applied to a second
+# modality/dataset, MMK12, reusing the existing GSPO MMK12 data pipeline and
+# reward scorer. Same token-level DAPO policy contract as the AVQA dynamic
+# sampling launcher: vanilla clip-higher, token-mean, GRPO advantages, no KL,
+# algorithm.filter_groups enabled on the streaming `dapo` reward manager, and
+# the overlong response-length buffer from Phase 2.
+#
+# Data preparation (run once):
+# pip install math-verify
+# python examples/gspo_trainer/data_process/mmk12.py \
+# --local_dataset_path \
+# --local_save_dir ~/data/mmk12
+#
+# Runtime dependencies (all Ray worker nodes):
+# pip install math-verify # required by mmk12_reward.py
+# pip install qwen-vl-utils # required for multimodal data processing
+
+set -xeuo pipefail
+
+# Make verl_omni available to Ray workers.
+export VERL_USE_EXTERNAL_MODULES=verl_omni
+
+MODEL_PATH=${MODEL_PATH:-"$HOME/models/Qwen/Qwen3-Omni-30B-A3B-Instruct"}
+TRAIN_FILE=${TRAIN_FILE:-"$HOME/data/mmk12/train.parquet"}
+VAL_FILE=${VAL_FILE:-"$HOME/data/mmk12/test.parquet"}
+
+python3 -m verl_omni.trainer.main_omni \
+ data.train_files="${TRAIN_FILE}" \
+ data.val_files="${VAL_FILE}" \
+ data.train_batch_size=128 \
+ data.max_prompt_length=4096 \
+ data.max_response_length=12288 \
+ data.shuffle=true \
+ data.seed=42 \
+ data.val_max_samples=-1 \
+ data.validation_shuffle=false \
+ data.truncation='error' \
+ data.filter_overlong_prompts=true \
+ actor_rollout_ref.model.path="${MODEL_PATH}" \
+ actor_rollout_ref.model.lora_rank=32 \
+ actor_rollout_ref.model.lora_alpha=64 \
+ actor_rollout_ref.model.lora_dtype=float32 \
+ actor_rollout_ref.model.lora.merge=true \
+ actor_rollout_ref.model.enable_gradient_checkpointing=true \
+ actor_rollout_ref.model.use_remove_padding=true \
+ actor_rollout_ref.model.exclude_modules=".*talker.*|.*code2wav.*|.*code_predictor.*|.*visual.*|.*audio_tower.*" \
+ actor_rollout_ref.model.target_modules="['q_proj','k_proj','v_proj','o_proj']" \
+ actor_rollout_ref.actor.freeze_vision_tower=true \
+ actor_rollout_ref.actor.strategy=fsdp2 \
+ actor_rollout_ref.actor.optim.lr=3e-6 \
+ actor_rollout_ref.actor.optim.weight_decay=0.01 \
+ actor_rollout_ref.actor.optim.clip_grad=1.0 \
+ actor_rollout_ref.actor.ppo_mini_batch_size=16 \
+ actor_rollout_ref.actor.use_dynamic_bsz=true \
+ actor_rollout_ref.actor.ppo_max_token_len_per_gpu=30720 \
+ actor_rollout_ref.actor.use_kl_loss=false \
+ actor_rollout_ref.actor.entropy_coeff=0 \
+ actor_rollout_ref.actor.policy_loss.loss_mode=vanilla \
+ actor_rollout_ref.actor.clip_ratio_low=0.2 \
+ actor_rollout_ref.actor.clip_ratio_high=0.28 \
+ actor_rollout_ref.actor.clip_ratio_c=10.0 \
+ actor_rollout_ref.actor.loss_agg_mode=token-mean \
+ actor_rollout_ref.actor.fsdp_config.model_dtype=bfloat16 \
+ actor_rollout_ref.actor.fsdp_config.param_offload=true \
+ actor_rollout_ref.actor.fsdp_config.optimizer_offload=true \
+ actor_rollout_ref.rollout.name=vllm_omni \
+ actor_rollout_ref.rollout.n=16 \
+ actor_rollout_ref.rollout.tensor_model_parallel_size=2 \
+ actor_rollout_ref.rollout.gpu_memory_utilization=0.8 \
+ actor_rollout_ref.rollout.load_format=safetensors \
+ actor_rollout_ref.rollout.prompt_length=4160 \
+ actor_rollout_ref.rollout.log_prob_use_dynamic_bsz=true \
+ actor_rollout_ref.rollout.log_prob_max_token_len_per_gpu=30720 \
+ actor_rollout_ref.rollout.enable_prefix_caching=false \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.output_mode="ar" \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.pipeline_name="qwen3_omni_moe" \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.max_num_seqs=256 \
+ actor_rollout_ref.rollout.val_kwargs.n=1 \
+ actor_rollout_ref.rollout.val_kwargs.do_sample=false \
+ actor_rollout_ref.rollout.val_kwargs.temperature=0 \
+ actor_rollout_ref.rollout.val_kwargs.top_p=1.0 \
+ actor_rollout_ref.rollout.val_kwargs.top_k=-1 \
+ actor_rollout_ref.ref.log_prob_use_dynamic_bsz=true \
+ actor_rollout_ref.ref.log_prob_max_token_len_per_gpu=30720 \
+ actor_rollout_ref.ref.fsdp_config.param_offload=true \
+ actor_rollout_ref.ref.fsdp_config.model_dtype=bfloat16 \
+ algorithm.trainer_type=policy_gradient \
+ algorithm.sample_source=online \
+ algorithm.adv_estimator=grpo \
+ algorithm.use_kl_in_reward=false \
+ algorithm.filter_groups.enable=true \
+ algorithm.filter_groups.metric=acc \
+ algorithm.filter_groups.max_inflight_gen_batches=2 \
+ reward.reward_model.enable=false \
+ reward.reward_manager.source=register \
+ reward.reward_manager.name=dapo \
+ reward.custom_reward_function.path=verl_omni/utils/reward_score/mmk12_reward.py \
+ reward.custom_reward_function.name=compute_score \
+ reward.reward_kwargs.max_resp_len=12288 \
+ reward.reward_kwargs.overlong_buffer_cfg.enable=true \
+ reward.reward_kwargs.overlong_buffer_cfg.len=1024 \
+ reward.reward_kwargs.overlong_buffer_cfg.penalty_factor=1.0 \
+ reward.reward_kwargs.overlong_buffer_cfg.log=true \
+ trainer.val_before_train=true \
+ trainer.balance_batch=true \
+ trainer.critic_warmup=0 \
+ trainer.logger='["console","wandb"]' \
+ trainer.project_name=dapo \
+ trainer.experiment_name=qwen3_omni_thinker_lora_mmk12_dynamic_sampling \
+ trainer.n_gpus_per_node=4 \
+ trainer.nnodes=1 \
+ trainer.save_freq=50 \
+ trainer.test_freq=10 \
+ trainer.total_epochs=10 \
+ "$@"
diff --git a/examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_v1.sh b/examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_v1.sh
new file mode 100755
index 000000000..57cbd73db
--- /dev/null
+++ b/examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_v1.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+# Qwen3-Omni Thinker DAPO + LoRA training on AVQA without dynamic sampling.
+# Data preparation (run once):
+# python examples/gspo_trainer/data_process/avqa.py \
+# --input_dir \
+# --output_dir ~/data/avqa_r1_6k
+#
+# Runtime dependencies (all Ray worker nodes):
+# pip install -e ".[audio]"
+# pip install qwen-vl-utils
+
+set -xeuo pipefail
+
+# Make verl_omni available to Ray workers.
+export VERL_USE_EXTERNAL_MODULES=verl_omni
+
+MODEL_PATH=${MODEL_PATH:-"$HOME/models/Qwen/Qwen3-Omni-30B-A3B-Instruct"}
+TRAIN_FILE=${TRAIN_FILE:-"$HOME/data/avqa_r1_6k/train.parquet"}
+VAL_FILE=${VAL_FILE:-"$HOME/data/avqa_r1_6k/validation.parquet"}
+
+python3 -m verl_omni.trainer.main_omni \
+ data.train_files="${TRAIN_FILE}" \
+ data.val_files="${VAL_FILE}" \
+ data.train_batch_size=128 \
+ data.max_prompt_length=4096 \
+ data.max_response_length=12288 \
+ data.shuffle=true \
+ data.seed=42 \
+ data.val_max_samples=-1 \
+ data.validation_shuffle=false \
+ data.truncation='error' \
+ data.filter_overlong_prompts=true \
+ data.custom_cls.path=pkg://verl_omni.utils.dataset.omni_rl_datasets \
+ data.custom_cls.name=QwenOmniRLHFDataset \
+ +data.mm_processor_kwargs.sampling_rate=16000 \
+ actor_rollout_ref.model.path="${MODEL_PATH}" \
+ actor_rollout_ref.model.lora_rank=32 \
+ actor_rollout_ref.model.lora_alpha=64 \
+ actor_rollout_ref.model.lora_dtype=float32 \
+ actor_rollout_ref.model.lora.merge=true \
+ actor_rollout_ref.model.enable_gradient_checkpointing=true \
+ actor_rollout_ref.model.use_remove_padding=true \
+ actor_rollout_ref.model.exclude_modules=".*talker.*|.*code2wav.*|.*code_predictor.*|.*visual.*|.*audio_tower.*" \
+ actor_rollout_ref.model.target_modules="['q_proj','k_proj','v_proj','o_proj']" \
+ actor_rollout_ref.actor.freeze_vision_tower=true \
+ actor_rollout_ref.actor.strategy=fsdp2 \
+ actor_rollout_ref.actor.optim.lr=3e-6 \
+ actor_rollout_ref.actor.optim.weight_decay=0.01 \
+ actor_rollout_ref.actor.optim.clip_grad=1.0 \
+ actor_rollout_ref.actor.ppo_mini_batch_size=16 \
+ actor_rollout_ref.actor.use_dynamic_bsz=true \
+ actor_rollout_ref.actor.ppo_max_token_len_per_gpu=20480 \
+ actor_rollout_ref.actor.use_kl_loss=false \
+ actor_rollout_ref.actor.entropy_coeff=0 \
+ actor_rollout_ref.actor.policy_loss.loss_mode=vanilla \
+ actor_rollout_ref.actor.clip_ratio_low=0.2 \
+ actor_rollout_ref.actor.clip_ratio_high=0.28 \
+ actor_rollout_ref.actor.clip_ratio_c=10.0 \
+ actor_rollout_ref.actor.loss_agg_mode=token-mean \
+ actor_rollout_ref.actor.fsdp_config.model_dtype=bfloat16 \
+ actor_rollout_ref.actor.fsdp_config.param_offload=true \
+ actor_rollout_ref.actor.fsdp_config.optimizer_offload=true \
+ actor_rollout_ref.rollout.name=vllm_omni \
+ actor_rollout_ref.rollout.n=16 \
+ actor_rollout_ref.rollout.tensor_model_parallel_size=2 \
+ actor_rollout_ref.rollout.gpu_memory_utilization=0.7 \
+ actor_rollout_ref.rollout.load_format=safetensors \
+ actor_rollout_ref.rollout.prompt_length=4160 \
+ actor_rollout_ref.rollout.log_prob_use_dynamic_bsz=true \
+ actor_rollout_ref.rollout.log_prob_max_token_len_per_gpu=20480 \
+ actor_rollout_ref.rollout.enable_prefix_caching=false \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.output_mode="ar" \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.pipeline_name="qwen3_omni_moe" \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.max_num_seqs=256 \
+ actor_rollout_ref.rollout.cudagraph_capture_sizes=[1,2,4,8,16,32,64,128,256] \
+ actor_rollout_ref.rollout.val_kwargs.n=1 \
+ actor_rollout_ref.rollout.val_kwargs.do_sample=false \
+ actor_rollout_ref.rollout.val_kwargs.temperature=0 \
+ actor_rollout_ref.rollout.val_kwargs.top_p=1.0 \
+ actor_rollout_ref.rollout.val_kwargs.top_k=-1 \
+ actor_rollout_ref.ref.log_prob_use_dynamic_bsz=true \
+ actor_rollout_ref.ref.log_prob_max_token_len_per_gpu=20480 \
+ actor_rollout_ref.ref.fsdp_config.param_offload=true \
+ actor_rollout_ref.ref.fsdp_config.model_dtype=bfloat16 \
+ algorithm.trainer_type=policy_gradient \
+ algorithm.sample_source=online \
+ algorithm.adv_estimator=grpo \
+ algorithm.use_kl_in_reward=false \
+ algorithm.filter_groups.enable=false \
+ reward.reward_manager.source=register \
+ reward.reward_manager.name=naive \
+ reward.custom_reward_function.path=verl_omni/utils/reward_score/choice_reward.py \
+ reward.custom_reward_function.name=compute_score \
+ trainer.val_before_train=true \
+ trainer.balance_batch=true \
+ trainer.critic_warmup=0 \
+ trainer.logger='["console","wandb"]' \
+ trainer.project_name=dapo \
+ trainer.experiment_name=qwen3_omni_thinker_lora_avqa_wo_dynamic_sampling \
+ trainer.n_gpus_per_node=4 \
+ trainer.nnodes=1 \
+ trainer.save_freq=50 \
+ trainer.test_freq=10 \
+ trainer.total_epochs=10 \
+ "$@"
diff --git a/tests/special_e2e/run_dapo_qwen3_omni_thinker_lora_v1_smoke.sh b/tests/special_e2e/run_dapo_qwen3_omni_thinker_lora_v1_smoke.sh
new file mode 100755
index 000000000..cdab2bd31
--- /dev/null
+++ b/tests/special_e2e/run_dapo_qwen3_omni_thinker_lora_v1_smoke.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# Qwen3-Omni Thinker DAPO + LoRA V1 smoke without dynamic sampling.
+
+set -xeuo pipefail
+
+if [[ "${SKIP_COMPAT_DEPS_INSTALL:-0}" != "1" ]]; then
+ uv pip install --system --break-system-packages transformers==5.12.1 accelerate==1.14.0 peft==0.19.1
+fi
+
+export NCCL_IB_DISABLE=1
+export CPATH=/usr/include${CPATH:+:$CPATH}
+export RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO=0
+export VERL_USE_EXTERNAL_MODULES=verl_omni
+
+NUM_GPUS=${NUM_GPUS:-2}
+MODEL_PATH=${MODEL_PATH:-}
+DATA_DIR=${DATA_DIR:-${HOME}/data/gsm8k}
+TOTAL_TRAIN_STEPS=${TOTAL_TRAIN_STEPS:-2}
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+EXCLUDE_MODULES=".*talker.*|.*code2wav.*|.*code_predictor.*|.*visual.*|.*audio_tower.*"
+
+MODEL_PATH="${MODEL_PATH:-${HOME}/models/tiny-random/Qwen3-Omni}"
+python3 "${REPO_ROOT}/tests/special_e2e/build_qwen3_omni_tiny_random.py" \
+ --output-dir "${MODEL_PATH}" --force
+
+if [ ! -f "${DATA_DIR}/train.parquet" ]; then
+ python3 "${REPO_ROOT}/tests/special_e2e/create_dummy_math_data.py" \
+ --local_save_dir "${DATA_DIR}"
+fi
+
+python3 -m verl_omni.trainer.main_omni \
+ data.train_files="${DATA_DIR}/train.parquet" \
+ data.val_files="${DATA_DIR}/test.parquet" \
+ data.train_batch_size=4 \
+ data.max_prompt_length=256 \
+ data.max_response_length=512 \
+ data.val_max_samples=4 \
+ data.truncation='error' \
+ data.filter_overlong_prompts=true \
+ actor_rollout_ref.model.path="${MODEL_PATH}" \
+ +actor_rollout_ref.model.override_config.attn_implementation=sdpa \
+ actor_rollout_ref.model.lora_rank=8 \
+ actor_rollout_ref.model.lora_alpha=16 \
+ actor_rollout_ref.model.lora_dtype=float32 \
+ actor_rollout_ref.model.lora.merge=true \
+ actor_rollout_ref.model.enable_gradient_checkpointing=true \
+ actor_rollout_ref.model.use_remove_padding=true \
+ actor_rollout_ref.model.exclude_modules="${EXCLUDE_MODULES}" \
+ actor_rollout_ref.model.target_modules="['q_proj','k_proj','v_proj','o_proj']" \
+ actor_rollout_ref.actor.freeze_vision_tower=true \
+ actor_rollout_ref.actor.strategy=fsdp2 \
+ actor_rollout_ref.actor.optim.lr=3e-6 \
+ actor_rollout_ref.actor.optim.weight_decay=0.01 \
+ actor_rollout_ref.actor.optim.clip_grad=1.0 \
+ actor_rollout_ref.actor.ppo_mini_batch_size=4 \
+ actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=1 \
+ actor_rollout_ref.actor.use_dynamic_bsz=true \
+ actor_rollout_ref.actor.ppo_max_token_len_per_gpu=20480 \
+ actor_rollout_ref.actor.use_kl_loss=false \
+ actor_rollout_ref.actor.entropy_coeff=0 \
+ actor_rollout_ref.actor.policy_loss.loss_mode=vanilla \
+ actor_rollout_ref.actor.clip_ratio_low=0.2 \
+ actor_rollout_ref.actor.clip_ratio_high=0.28 \
+ actor_rollout_ref.actor.clip_ratio_c=10.0 \
+ actor_rollout_ref.actor.loss_agg_mode=token-mean \
+ actor_rollout_ref.actor.fsdp_config.model_dtype=bfloat16 \
+ actor_rollout_ref.actor.fsdp_config.param_offload=true \
+ actor_rollout_ref.actor.fsdp_config.optimizer_offload=true \
+ actor_rollout_ref.rollout.name=vllm_omni \
+ actor_rollout_ref.rollout.n=2 \
+ actor_rollout_ref.rollout.temperature=0.8 \
+ actor_rollout_ref.rollout.tensor_model_parallel_size="${NUM_GPUS}" \
+ actor_rollout_ref.rollout.gpu_memory_utilization=0.4 \
+ actor_rollout_ref.rollout.max_num_seqs=16 \
+ actor_rollout_ref.rollout.load_format=safetensors \
+ actor_rollout_ref.rollout.log_prob_use_dynamic_bsz=true \
+ actor_rollout_ref.rollout.log_prob_max_token_len_per_gpu=20480 \
+ actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=2 \
+ actor_rollout_ref.rollout.enable_prefix_caching=false \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.output_mode="ar" \
+ +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.pipeline_name="qwen3_omni_moe" \
+ actor_rollout_ref.rollout.val_kwargs.n=1 \
+ actor_rollout_ref.rollout.val_kwargs.temperature=1.0 \
+ actor_rollout_ref.rollout.val_kwargs.top_p=0.7 \
+ actor_rollout_ref.ref.strategy=fsdp2 \
+ actor_rollout_ref.ref.log_prob_use_dynamic_bsz=true \
+ actor_rollout_ref.ref.log_prob_max_token_len_per_gpu=20480 \
+ actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=2 \
+ actor_rollout_ref.ref.fsdp_config.param_offload=true \
+ actor_rollout_ref.ref.fsdp_config.model_dtype=bfloat16 \
+ algorithm.trainer_type=policy_gradient \
+ algorithm.sample_source=online \
+ algorithm.adv_estimator=grpo \
+ algorithm.use_kl_in_reward=false \
+ algorithm.filter_groups.enable=false \
+ reward.reward_manager.source=register \
+ reward.reward_manager.name=dapo \
+ reward.reward_kwargs.max_resp_len=512 \
+ reward.reward_kwargs.overlong_buffer_cfg.enable=true \
+ reward.reward_kwargs.overlong_buffer_cfg.len=128 \
+ reward.reward_kwargs.overlong_buffer_cfg.penalty_factor=1.0 \
+ reward.reward_kwargs.overlong_buffer_cfg.log=true \
+ trainer.val_before_train=false \
+ trainer.balance_batch=true \
+ trainer.critic_warmup=0 \
+ trainer.logger=console \
+ trainer.project_name=verl-test \
+ trainer.experiment_name=dapo-qwen3-omni-thinker-lora-e2e-v1-wo-dynamic-sampling \
+ trainer.n_gpus_per_node="${NUM_GPUS}" \
+ trainer.nnodes=1 \
+ trainer.test_freq=1 \
+ trainer.save_freq=-1 \
+ trainer.resume_mode=disable \
+ trainer.total_training_steps="${TOTAL_TRAIN_STEPS}" \
+ "$@"
+
+echo "Qwen3-Omni Thinker DAPO+LoRA e2e V1 smoke without dynamic sampling passed."
diff --git a/tests/utils/test_dapo_overlong_reward_on_cpu.py b/tests/utils/test_dapo_overlong_reward_on_cpu.py
new file mode 100644
index 000000000..87d4a3574
--- /dev/null
+++ b/tests/utils/test_dapo_overlong_reward_on_cpu.py
@@ -0,0 +1,82 @@
+# Copyright 2026 Bytedance Ltd. and/or its affiliates
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Overlong penalty must change the reward on a truncated response (#446 Phase 2)."""
+
+import numpy as np
+import torch
+from omegaconf import OmegaConf
+from transformers import AutoTokenizer
+from verl import DataProto
+from verl.experimental.reward_loop.reward_manager.dapo import DAPORewardManager
+
+MAX_RESP_LEN = 16
+OVERLONG_BUFFER_LEN = 4
+OVERLONG_PENALTY_FACTOR = 1.0
+
+
+def _compute_score(data_source, solution_str, ground_truth, extra_info=None):
+ return 1.0
+
+
+def _build_manager(overlong_enable: bool) -> DAPORewardManager:
+ config = OmegaConf.create(
+ {
+ "reward": {
+ "reward_kwargs": {
+ "overlong_buffer_cfg": {
+ "enable": overlong_enable,
+ "len": OVERLONG_BUFFER_LEN,
+ "penalty_factor": OVERLONG_PENALTY_FACTOR,
+ "log": True,
+ },
+ "max_resp_len": MAX_RESP_LEN,
+ }
+ }
+ }
+ )
+ tokenizer = AutoTokenizer.from_pretrained("gpt2")
+ return DAPORewardManager(config, tokenizer, _compute_score)
+
+
+def _make_truncated_response() -> DataProto:
+ # valid_len == MAX_RESP_LEN, i.e. exceed_len == OVERLONG_BUFFER_LEN -> full penalty.
+ response_ids = torch.randint(0, 100, (1, MAX_RESP_LEN))
+ attention_mask = torch.ones(1, MAX_RESP_LEN, dtype=torch.long)
+ non_tensors = {
+ "data_source": np.array(["dummy"], dtype=object),
+ "reward_model": np.array([{"ground_truth": "x"}], dtype=object),
+ "extra_info": np.array([{}], dtype=object),
+ }
+ return DataProto.from_dict(
+ tensors={"responses": response_ids, "attention_mask": attention_mask},
+ non_tensors=non_tensors,
+ )
+
+
+def test_overlong_penalty_changes_reward_on_truncated_response():
+ batch = _make_truncated_response()
+
+ disabled = _build_manager(overlong_enable=False)
+ enabled = _build_manager(overlong_enable=True)
+
+ result_disabled = disabled.loop.run_until_complete(disabled.run_single(batch))
+ result_enabled = enabled.loop.run_until_complete(enabled.run_single(batch))
+
+ assert result_disabled["reward_score"] == 1.0
+ assert "overlong" not in result_disabled["reward_extra_info"]
+
+ assert result_enabled["reward_score"] < result_disabled["reward_score"]
+ assert result_enabled["reward_extra_info"]["overlong"]
+ assert result_enabled["reward_extra_info"]["overlong_reward"] < 0
diff --git a/tests/utils/test_qwen3_omni_dapo_launcher_on_cpu.py b/tests/utils/test_qwen3_omni_dapo_launcher_on_cpu.py
new file mode 100644
index 000000000..aa4f9f426
--- /dev/null
+++ b/tests/utils/test_qwen3_omni_dapo_launcher_on_cpu.py
@@ -0,0 +1,154 @@
+# Copyright 2026 Bytedance Ltd. and/or its affiliates
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from pathlib import Path
+
+DAPO_WITHOUT_DYNAMIC_SAMPLING_SETTINGS = (
+ "python3 -m verl_omni.trainer.main_omni",
+ "actor_rollout_ref.actor.policy_loss.loss_mode=vanilla",
+ "actor_rollout_ref.actor.clip_ratio_low=0.2",
+ "actor_rollout_ref.actor.clip_ratio_high=0.28",
+ "actor_rollout_ref.actor.clip_ratio_c=10.0",
+ "actor_rollout_ref.actor.loss_agg_mode=token-mean",
+ "actor_rollout_ref.actor.use_kl_loss=false",
+ "actor_rollout_ref.actor.entropy_coeff=0",
+ "algorithm.trainer_type=policy_gradient",
+ "algorithm.sample_source=online",
+ "algorithm.adv_estimator=grpo",
+ "algorithm.use_kl_in_reward=false",
+ "algorithm.filter_groups.enable=false",
+ "reward.reward_manager.source=register",
+ '+actor_rollout_ref.rollout.engine_kwargs.vllm_omni.pipeline_name="qwen3_omni_moe"',
+)
+
+
+def _script_settings(script: str) -> set[str]:
+ return {line.strip().removesuffix("\\").rstrip() for line in script.splitlines()}
+
+
+def _assert_dapo_without_dynamic_sampling_contract(script: str) -> None:
+ settings = _script_settings(script)
+ assert set(DAPO_WITHOUT_DYNAMIC_SAMPLING_SETTINGS) <= settings
+ assert "actor_rollout_ref.actor.policy_loss.loss_mode=gspo" not in settings
+ assert "algorithm.filter_groups.enable=true" not in settings
+
+
+def test_dapo_example_launcher_has_phase_one_contract():
+ repo_root = Path(__file__).parents[2]
+ launcher = (repo_root / "examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_v1.sh").read_text(
+ encoding="utf-8"
+ )
+
+ _assert_dapo_without_dynamic_sampling_contract(launcher)
+ settings = _script_settings(launcher)
+ assert ".*talker.*|.*code2wav.*|.*code_predictor.*|.*visual.*|.*audio_tower.*" in launcher
+ assert "actor_rollout_ref.actor.freeze_vision_tower=true" in settings
+ assert 'TRAIN_FILE=${TRAIN_FILE:-"$HOME/data/avqa_r1_6k/train.parquet"}' in launcher
+ assert 'VAL_FILE=${VAL_FILE:-"$HOME/data/avqa_r1_6k/validation.parquet"}' in launcher
+ assert {
+ "data.custom_cls.name=QwenOmniRLHFDataset",
+ "data.seed=42",
+ "data.val_max_samples=-1",
+ "data.validation_shuffle=false",
+ "reward.reward_manager.name=naive",
+ "reward.custom_reward_function.path=verl_omni/utils/reward_score/choice_reward.py",
+ "reward.custom_reward_function.name=compute_score",
+ "trainer.val_before_train=true",
+ "trainer.test_freq=10",
+ "actor_rollout_ref.rollout.val_kwargs.n=1",
+ "actor_rollout_ref.rollout.val_kwargs.do_sample=false",
+ "actor_rollout_ref.rollout.val_kwargs.temperature=0",
+ "actor_rollout_ref.rollout.val_kwargs.top_p=1.0",
+ "actor_rollout_ref.rollout.val_kwargs.top_k=-1",
+ } <= settings
+ assert "actor_rollout_ref.rollout.val_kwargs.temperature=1.0" not in settings
+ assert "actor_rollout_ref.rollout.val_kwargs.top_p=0.7" not in settings
+ assert "data.val_max_samples=4" not in settings
+ assert "data.validation_shuffle=true" not in settings
+ assert "trainer.val_before_train=false" not in settings
+ assert "overlong_buffer_cfg" not in launcher
+
+
+def test_dapo_tiny_random_smoke_matches_example_contract():
+ repo_root = Path(__file__).parents[2]
+ smoke = (repo_root / "tests/special_e2e/run_dapo_qwen3_omni_thinker_lora_v1_smoke.sh").read_text(encoding="utf-8")
+
+ _assert_dapo_without_dynamic_sampling_contract(smoke)
+ settings = _script_settings(smoke)
+ assert "reward.reward_manager.name=dapo" in settings
+ assert "build_qwen3_omni_tiny_random.py" in smoke
+ assert "SKIP_COMPAT_DEPS_INSTALL:-0" in smoke
+ assert 'trainer.total_training_steps="${TOTAL_TRAIN_STEPS}"' in smoke
+
+ assert "data.max_response_length=512" in settings
+ assert {
+ "reward.reward_kwargs.max_resp_len=512",
+ "reward.reward_kwargs.overlong_buffer_cfg.enable=true",
+ "reward.reward_kwargs.overlong_buffer_cfg.len=128",
+ "reward.reward_kwargs.overlong_buffer_cfg.penalty_factor=1.0",
+ "reward.reward_kwargs.overlong_buffer_cfg.log=true",
+ } <= settings
+
+
+def test_dapo_dynamic_sampling_example_launcher_has_phase_three_contract():
+ repo_root = Path(__file__).parents[2]
+ launcher = (
+ repo_root
+ / "examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_dynamic_sampling_lora_v1.sh"
+ ).read_text(encoding="utf-8")
+
+ settings = _script_settings(launcher)
+ # Same token-level DAPO policy contract as Phase 1, minus filter_groups.enable=false.
+ assert set(DAPO_WITHOUT_DYNAMIC_SAMPLING_SETTINGS) - {"algorithm.filter_groups.enable=false"} <= settings
+ assert "actor_rollout_ref.actor.policy_loss.loss_mode=gspo" not in settings
+ assert ".*talker.*|.*code2wav.*|.*code_predictor.*|.*visual.*|.*audio_tower.*" in launcher
+ assert "actor_rollout_ref.actor.freeze_vision_tower=true" in settings
+ assert {
+ "algorithm.filter_groups.enable=true",
+ "algorithm.filter_groups.metric=acc",
+ "reward.reward_model.enable=false",
+ "reward.reward_manager.name=dapo",
+ "reward.custom_reward_function.path=verl_omni/utils/reward_score/choice_reward.py",
+ "reward.custom_reward_function.name=compute_score",
+ } <= settings
+ # filter_groups requires the streaming (non-colocated) reward path.
+ assert "reward.reward_model.enable=true" not in settings
+
+
+def test_dapo_mmk12_example_launcher_has_phase_four_contract():
+ repo_root = Path(__file__).parents[2]
+ launcher = (
+ repo_root / "examples/dapo_trainer/qwen3_omni/run_qwen3_omni_thinker_dapo_lora_mmk12_v1.sh"
+ ).read_text(encoding="utf-8")
+
+ settings = _script_settings(launcher)
+ # Same token-level DAPO policy contract as the AVQA dynamic sampling launcher.
+ assert set(DAPO_WITHOUT_DYNAMIC_SAMPLING_SETTINGS) - {"algorithm.filter_groups.enable=false"} <= settings
+ assert "actor_rollout_ref.actor.policy_loss.loss_mode=gspo" not in settings
+ assert ".*talker.*|.*code2wav.*|.*code_predictor.*|.*visual.*|.*audio_tower.*" in launcher
+ assert "actor_rollout_ref.actor.freeze_vision_tower=true" in settings
+ assert {
+ "algorithm.filter_groups.enable=true",
+ "algorithm.filter_groups.metric=acc",
+ "reward.reward_model.enable=false",
+ "reward.reward_manager.name=dapo",
+ "reward.custom_reward_function.path=verl_omni/utils/reward_score/mmk12_reward.py",
+ "reward.custom_reward_function.name=compute_score",
+ "reward.reward_kwargs.overlong_buffer_cfg.enable=true",
+ "reward.reward_kwargs.overlong_buffer_cfg.len=1024",
+ "reward.reward_kwargs.overlong_buffer_cfg.penalty_factor=1.0",
+ "reward.reward_kwargs.max_resp_len=12288",
+ } <= settings
+ assert 'TRAIN_FILE=${TRAIN_FILE:-"$HOME/data/mmk12/train.parquet"}' in launcher
+ assert 'VAL_FILE=${VAL_FILE:-"$HOME/data/mmk12/test.parquet"}' in launcher
diff --git a/tests/workers/config/test_omni_config_on_cpu.py b/tests/workers/config/test_omni_config_on_cpu.py
index a73e34a4e..ffbd7c077 100644
--- a/tests/workers/config/test_omni_config_on_cpu.py
+++ b/tests/workers/config/test_omni_config_on_cpu.py
@@ -17,6 +17,7 @@
import os
import pytest
+from verl.trainer.config.algorithm import AlgoConfig, FilterGroupsConfig
from verl.workers.config.model import MtpConfig
from verl_omni.trainer.config.algorithm import OmniAlgoConfig
@@ -33,6 +34,10 @@ def test_defaults(self):
assert cfg.adv_estimator == "dpo"
assert cfg.norm_adv_by_std_in_grpo is True
assert cfg.global_std is True
+ assert isinstance(cfg, AlgoConfig)
+ assert cfg.use_kl_in_reward is False
+ assert cfg.filter_groups is None
+ assert cfg.kl_ctrl.type == "fixed"
@pytest.mark.parametrize(
"field_name, value",
@@ -45,6 +50,47 @@ def test_invalid_values_raise(self, field_name, value):
with pytest.raises(ValueError):
OmniAlgoConfig(**{field_name: value})
+ def test_inherited_online_fields_survive_hydra_conversion(self):
+ from hydra import compose, initialize_config_dir
+ from verl.utils.config import omega_conf_to_dataclass
+
+ import verl_omni
+
+ config_dir = os.path.join(os.path.dirname(verl_omni.__file__), "trainer/config")
+ with initialize_config_dir(config_dir=config_dir, version_base=None):
+ cfg = compose(
+ config_name="omni_trainer",
+ overrides=[
+ "algorithm.filter_groups.enable=true",
+ "algorithm.filter_groups.metric=acc",
+ "algorithm.filter_groups.max_num_gen_batches=7",
+ "algorithm.use_kl_in_reward=true",
+ "algorithm.kl_penalty=low_var_kl",
+ "algorithm.kl_ctrl.type=adaptive",
+ "algorithm.kl_ctrl.kl_coef=0.02",
+ "algorithm.kl_ctrl.target_kl=0.03",
+ "algorithm.rollout_correction.rollout_is=token",
+ "algorithm.rollout_correction.rollout_is_threshold=3.0",
+ ],
+ )
+
+ algorithm_cfg: OmniAlgoConfig = omega_conf_to_dataclass(cfg.algorithm)
+
+ assert isinstance(algorithm_cfg, OmniAlgoConfig)
+ assert isinstance(algorithm_cfg, AlgoConfig)
+ assert isinstance(algorithm_cfg.filter_groups, FilterGroupsConfig)
+ assert algorithm_cfg.filter_groups.enable is True
+ assert algorithm_cfg.filter_groups.metric == "acc"
+ assert algorithm_cfg.filter_groups.max_num_gen_batches == 7
+ assert algorithm_cfg.use_kl_in_reward is True
+ assert algorithm_cfg.kl_penalty == "low_var_kl"
+ assert algorithm_cfg.kl_ctrl.type == "adaptive"
+ assert algorithm_cfg.kl_ctrl.kl_coef == pytest.approx(0.02)
+ assert algorithm_cfg.kl_ctrl.target_kl == pytest.approx(0.03)
+ assert algorithm_cfg.rollout_correction is not None
+ assert algorithm_cfg.rollout_correction.get("rollout_is") == "token"
+ assert algorithm_cfg.rollout_correction.get("rollout_is_threshold") == pytest.approx(3.0)
+
class TestOmniLossConfig:
def test_defaults(self):
diff --git a/verl_omni/trainer/config/_generated_diffusion_trainer.yaml b/verl_omni/trainer/config/_generated_diffusion_trainer.yaml
index 5037b7875..235383b16 100644
--- a/verl_omni/trainer/config/_generated_diffusion_trainer.yaml
+++ b/verl_omni/trainer/config/_generated_diffusion_trainer.yaml
@@ -468,6 +468,13 @@ reward:
name: compute_score
reward_functions: {}
aggregation: weighted_sum
+ reward_kwargs:
+ overlong_buffer_cfg:
+ enable: false
+ len: 0
+ penalty_factor: 0.0
+ log: true
+ max_resp_len: null
reward_manager:
_target_: verl.workers.config.reward_model.RewardManagerConfig
source: importlib
diff --git a/verl_omni/trainer/config/_generated_diffusion_veomni_trainer.yaml b/verl_omni/trainer/config/_generated_diffusion_veomni_trainer.yaml
index ca59c25e2..cbad00ed0 100644
--- a/verl_omni/trainer/config/_generated_diffusion_veomni_trainer.yaml
+++ b/verl_omni/trainer/config/_generated_diffusion_veomni_trainer.yaml
@@ -509,6 +509,13 @@ reward:
name: compute_score
reward_functions: {}
aggregation: weighted_sum
+ reward_kwargs:
+ overlong_buffer_cfg:
+ enable: false
+ len: 0
+ penalty_factor: 0.0
+ log: true
+ max_resp_len: null
reward_manager:
_target_: verl.workers.config.reward_model.RewardManagerConfig
source: importlib
diff --git a/verl_omni/trainer/config/_generated_omni_megatron_trainer.yaml b/verl_omni/trainer/config/_generated_omni_megatron_trainer.yaml
index 4784af554..e0f782bb0 100644
--- a/verl_omni/trainer/config/_generated_omni_megatron_trainer.yaml
+++ b/verl_omni/trainer/config/_generated_omni_megatron_trainer.yaml
@@ -912,6 +912,13 @@ reward:
name: compute_score
reward_functions: {}
aggregation: weighted_sum
+ reward_kwargs:
+ overlong_buffer_cfg:
+ enable: false
+ len: 0
+ penalty_factor: 0.0
+ log: true
+ max_resp_len: null
reward_manager:
_target_: verl.workers.config.reward_model.RewardManagerConfig
source: register
diff --git a/verl_omni/trainer/config/_generated_omni_trainer.yaml b/verl_omni/trainer/config/_generated_omni_trainer.yaml
index d25b41e55..a3721152c 100644
--- a/verl_omni/trainer/config/_generated_omni_trainer.yaml
+++ b/verl_omni/trainer/config/_generated_omni_trainer.yaml
@@ -872,6 +872,13 @@ reward:
name: compute_score
reward_functions: {}
aggregation: weighted_sum
+ reward_kwargs:
+ overlong_buffer_cfg:
+ enable: false
+ len: 0
+ penalty_factor: 0.0
+ log: true
+ max_resp_len: null
reward_manager:
_target_: verl.workers.config.reward_model.RewardManagerConfig
source: importlib
diff --git a/verl_omni/trainer/config/algorithm.py b/verl_omni/trainer/config/algorithm.py
index 5eaf4f137..3b97f9a31 100644
--- a/verl_omni/trainer/config/algorithm.py
+++ b/verl_omni/trainer/config/algorithm.py
@@ -18,7 +18,7 @@
from typing import Optional
from verl.base_config import BaseConfig
-from verl.trainer.config.algorithm import RolloutCorrectionConfig
+from verl.trainer.config.algorithm import AlgoConfig, RolloutCorrectionConfig
from verl_omni.trainer.diffusion.diffusion_trainer_utils import OLD_POLICY_DECAY_SCHEDULES
@@ -60,7 +60,7 @@ def __post_init__(self):
@dataclass
-class OmniAlgoConfig(BaseConfig):
+class OmniAlgoConfig(AlgoConfig):
"""Omni-specific algorithm config."""
trainer_type: str = "direct_preference"
diff --git a/verl_omni/trainer/config/reward/reward.yaml b/verl_omni/trainer/config/reward/reward.yaml
index c53ec73fb..03414a5e7 100644
--- a/verl_omni/trainer/config/reward/reward.yaml
+++ b/verl_omni/trainer/config/reward/reward.yaml
@@ -29,6 +29,16 @@ reward_functions: {}
# Aggregation method for multi-reward. Only "weighted_sum" is supported.
aggregation: weighted_sum
+# Extra kwargs read directly by the reward manager (e.g. verl's "dapo" manager).
+reward_kwargs:
+ # max_resp_len must exceed overlong_buffer_cfg.len when enable is true.
+ overlong_buffer_cfg:
+ enable: false
+ len: 0
+ penalty_factor: 0.0
+ log: true
+ max_resp_len: null
+
# reward manager configuration
reward_manager:
_target_: verl.workers.config.reward_model.RewardManagerConfig