diff --git a/.gitattributes b/.gitattributes
index 0fd17d2f7c330afce8bc2b472393143fa66f68a1..b6d6069c9fed01931405110b12cba17f1da5b723 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,43 @@
*.safetensors filter=lfs diff=lfs merge=lfs -text
tokenizer.json filter=lfs diff=lfs merge=lfs -text
+code/assets/orarl-data-scaling.gif filter=lfs diff=lfs merge=lfs -text
+code/assets/orarl-hero.gif filter=lfs diff=lfs merge=lfs -text
+code/assets/orarl-method.gif filter=lfs diff=lfs merge=lfs -text
+code/assets/orarl-model-scaling.gif filter=lfs diff=lfs merge=lfs -text
+code/assets/orarl-teaser.mp4 filter=lfs diff=lfs merge=lfs -text
+code/assets/paper-data-scaling.png filter=lfs diff=lfs merge=lfs -text
+code/assets/paper-framework.png filter=lfs diff=lfs merge=lfs -text
+code/assets/paper-model-scaling.png filter=lfs diff=lfs merge=lfs -text
+code/assets/paper-results.png filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_0.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_1.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_2.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_3.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_4.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_5.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_6.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_7.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_0.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_1.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_2.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_3.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_4.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_5.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_6.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_7.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_0.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_1.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_2.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_3.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_4.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_5.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_6.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_7.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_0.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_1.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_2.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_3.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_4.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_5.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_6.pt filter=lfs diff=lfs merge=lfs -text
+code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_7.pt filter=lfs diff=lfs merge=lfs -text
diff --git a/code/.gitignore b/code/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..26b0d7c7ee0d350fcb5620bae3df66190df74965
--- /dev/null
+++ b/code/.gitignore
@@ -0,0 +1,80 @@
+# Python and test state
+__pycache__/
+*.py[cod]
+*.egg-info/
+.pytest_cache/
+.ruff_cache/
+.mypy_cache/
+.nox/
+.tox/
+.coverage
+htmlcov/
+build/
+dist/
+.venv/
+venv/
+
+# Local inputs and generated runs
+/artifacts/
+/cache/
+/checkpoint/
+/checkpoints/
+/data/
+/local_data/
+/logs/
+/models/
+/outputs/
+/prepared/
+/ray_results/
+/runs/
+/wandb/
+/orarl-eval-summary.json
+nohup.out
+core.*
+
+# Model, media, and generated dataset payloads
+*.arrow
+*.avi
+*.bin
+*.ckpt
+*.jsonl
+*.mkv
+*.mov
+*.mp4
+*.npy
+*.npz
+*.parquet
+*.pt
+*.pth
+*.safetensors
+*.webm
+*.log
+
+# Only the small evaluation profile manifest is release source. Canonical
+# annotations, media, subtitles, and processed artifacts live on Hugging Face.
+!/data/
+/data/*
+!/data/eval/
+/data/eval/*
+!/data/eval/datasets.jsonl
+!/data/eval/README.md
+
+# README media shipped with the repository
+!/assets/orarl-teaser.mp4
+
+# Editor and operating-system state
+.DS_Store
+.idea/
+.ipynb_checkpoints/
+.vscode/
+*~
+
+# Local configuration
+.env
+.env.*
+*.local.yaml
+*.local.yml
+*.key
+*.pem
+credentials.json
+token
diff --git a/code/LICENSE b/code/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..c77c560e3c3592b45f6b463007e701e099c4fc05
--- /dev/null
+++ b/code/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 The OraRL Authors
+
+ 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.
diff --git a/code/MANIFEST.in b/code/MANIFEST.in
new file mode 100644
index 0000000000000000000000000000000000000000..fd11102cd1593b8341e9739e00c43f2531ddb036
--- /dev/null
+++ b/code/MANIFEST.in
@@ -0,0 +1,20 @@
+include LICENSE
+include NOTICE
+include README.md
+include environment.yml
+include pyproject.toml
+include requirements-cu129.txt
+recursive-include configs *.yaml
+recursive-include data/eval *.jsonl *.md
+recursive-include docs *.md
+recursive-include eval *.md *.py *.sh
+recursive-include scripts *.py *.sh
+recursive-include tests *.py
+recursive-include verl *.py
+prune build
+prune dist
+prune .pytest_cache
+prune .ruff_cache
+prune checkpoint
+prune checkpoints
+global-exclude __pycache__ *.py[cod] .DS_Store
diff --git a/code/NOTICE b/code/NOTICE
new file mode 100644
index 0000000000000000000000000000000000000000..ffe9c141d8619701b27b001e1745bfb4ec935218
--- /dev/null
+++ b/code/NOTICE
@@ -0,0 +1,7 @@
+OraRL
+Copyright 2026 The OraRL Authors
+
+Portions of this software are derived from veRL and other Apache-2.0 upstream
+projects. The derived portions retain their original copyright notices,
+including Copyright 2024 ByteDance Ltd. and/or its affiliates, and are provided
+under the Apache License, Version 2.0.
diff --git a/code/README.md b/code/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..b98a5405f22f9458f284d8fcff9bf8b373387505
--- /dev/null
+++ b/code/README.md
@@ -0,0 +1,201 @@
+
简体中文
+
+
+
+## Why OraRL
+
+- **Annotation-as-rollout:** annotations become reliable positive rollouts while
+ policy samples retain an on-policy baseline.
+- **Seven task families:** one update rule covers temporal and spatial grounding,
+ segmentation, tracking, spatial-temporal grounding, video QA, and spatial
+ intelligence.
+- **Efficient training (4B):** sign-balanced pruning delivers **1.48× faster
+ updates** (**92.5 → 62.4 s/step**) while reducing peak per-GPU memory from
+ **62.4 to 50.9 GB**.
+- **Efficient inference:** on one H20 with vLLM in BF16, weight loading occupies
+ **8.6 GiB (4B)** and **17.6 GiB (9B)**. On ten-minute, 2-fps videos,
+ answer-only decoding cuts median post-TTFT latency from **4.78 s to 130 ms**
+ and total latency from **29.03 to 24.30 s**.
+- **Multimodal veRL infrastructure:** a unified video contract carries cached
+ artifacts, raw paths, or inline frame tensors through vLLM rollouts and FSDP
+ updates, with decode-once frame reuse, temporal metadata, task-grouped
+ batching, asynchronous Ray rewards, and safe hybrid-engine cache handling.
+
+## OraRL in One Update
+
+
+
+
+
+An OraRL update separates reliable annotation guidance from on-policy
+normalization:
+
+1. **Build the group:** append one serialized annotation rollout to the policy
+ samples generated for the same prompt.
+2. **Keep the baseline on-policy:** estimate the group baseline from policy
+ rewards only.
+3. **Guide and select:** convert the annotation-policy reward gap into a
+ correction, then retain a sign-balanced subset for the update.
+
+This design uses task-native annotations directly and requires no
+chain-of-thought supervision or decoding.
+
+## Video-ORA Results
+
+
+
+
+
+### Dataset-Level Results
+
+
+
+
+
+
+
+Video-ORA-9B leads the matched seven-family comparison without CoT decoding.
+Best and second-best values are highlighted per row; `†` denotes an
+original-report value whose frame, prompt, split, or decoding settings may
+differ. Averages require complete family coverage.
+
+
+
+### Model Scaling
+
+
+
+
+
+### Data Scaling
+
+
+
+
+
+## Models
+
+| Model | Backbone | Released recipe | Weights |
+| --- | --- | --- | --- |
+| **Video-ORA-9B** | Qwen3.5-9B | `orarl_9b.yaml` | [Hugging Face](https://huggingface.co/OraRL/Video-ORA-9B) |
+| **Video-ORA-4B** | Qwen3.5-4B | `orarl_4b.yaml` | Hugging Face (coming soon) |
+
+### vLLM Serving
+
+Both Video-ORA checkpoints load directly with **vLLM 0.19.1** for
+OpenAI-compatible serving:
+
+```bash
+MODEL=OraRL/Video-ORA-9B
+
+vllm serve "$MODEL" \
+ --served-model-name Video-ORA-9B \
+ --trust-remote-code \
+ --dtype bfloat16 \
+ --tensor-parallel-size 1 \
+ --max-model-len 131072 \
+ --limit-mm-per-prompt '{"image": 1, "video": 1}'
+```
+
+Set `--tensor-parallel-size` to the GPU count for multi-GPU deployment and
+lower `--max-model-len` on smaller-memory devices. Use
+`enable_thinking=false` in the chat template for answer-only inference.
+
+## Use OraRL
+
+The release is organized around three user-facing workflows:
+
+1. **[Environment](docs/environment.md):** install the pinned CUDA stack that
+ covers both the bundled trainer and the evaluators.
+2. **[Training](docs/training.md):** prepare licensed local training data and
+ launch GRPO or OraRL on one or multiple nodes.
+3. **[Evaluation](docs/evaluation.md):** download Video-ORA and OraRL-Data,
+ then run a smoke test or the complete paper suite.
+
+Training and evaluation are dry runs by default; inspect the resolved command
+before adding `--run`. Checkpoints and evaluation media are hosted under the
+[OraRL Hugging Face organization](https://huggingface.co/OraRL).
+
+## Acknowledgements
+
+OraRL is built on [veRL](https://github.com/volcengine/verl) — a
+high-performance RL framework with HybridEngine. We thank its authors and
+contributors for open-sourcing the training infrastructure.
+
+## License
+
+OraRL source is released under [Apache-2.0](LICENSE). Datasets, models,
+benchmarks, and optional dependencies retain their original licenses; see
+[NOTICE](NOTICE).
+
+## Citation
+
+If you find OraRL useful, please consider giving this repository a ⭐ and
+citing our [paper](https://arxiv.org/abs/2608.20492).
+
+```bibtex
+@article{li2026orarl,
+ title = {Annotations as Rollouts: Efficient and Scalable
+ Reinforcement Learning for Video MLLMs},
+ author = {Li, Yunheng and Mu, Guohong and Li, Hao and
+ Qian, Shengsheng and Zhang, Dingwen and Hou, Qibin
+ and Cheng, Ming-Ming},
+ journal = {arXiv preprint arXiv:2608.20492},
+ year = {2026},
+ url = {https://arxiv.org/abs/2608.20492}
+}
+```
diff --git a/code/README_zh.md b/code/README_zh.md
new file mode 100644
index 0000000000000000000000000000000000000000..5c7824cc6a7ec68d004956ea1a5f6fda7bef22c9
--- /dev/null
+++ b/code/README_zh.md
@@ -0,0 +1,193 @@
+English
+
+
+
+# OraRL
+
+### 将标注作为 Rollout
+
+**面向统一视频多模态大模型的高效、可扩展强化学习**
+
+Yunheng Li · Guohong Mu · Hao Li · Shengsheng Qian · Dingwen Zhang ·
+Qibin Hou · Ming-Ming Cheng
+
+
+ 📄 论文
+ ·
+ 🌐 项目主页
+ ·
+ 🤗 模型(4B / 9B)
+
+
+ ⚙️ 环境
+ ·
+ 🚀 训练
+ ·
+ 📊 评测
+ ·
+ ⚖️ 许可证
+
+
+
+
+
+
+**▶ 点击图片观看 1 分 38 秒的项目介绍视频。**
+
+
+
+## 为什么选择 OraRL
+
+- **将标注作为 Rollout:** 将标注转化为可靠的正样本 rollout,同时策略样本
+ 仍使用纯 on-policy 基线。
+- **七类任务统一训练:** 同一套更新规则覆盖时序定位、空间定位、分割、跟踪、
+ 时空定位、视频问答和空间智能。
+- **高效训练(4B):** 符号均衡剪枝带来 **1.48× 的更新加速**
+ (**92.5 → 62.4 秒/步**),同时将单卡峰值显存从 **62.4 GB 降至
+ 50.9 GB**。
+- **高效推理:** 在单张 H20 上使用 vLLM 和 BF16 时,权重加载占用分别为
+ **8.6 GiB(4B)**和 **17.6 GiB(9B)**。对于 2 fps 采样的十分钟视频,
+ 仅生成答案将首 token 后的中位延迟从 **4.78 秒降至 130 毫秒**,并将总
+ 中位延迟从 **29.03 秒降至 24.30 秒**。
+- **多模态 veRL 基础设施:** 统一的视频数据契约支持缓存产物、原始路径和内联
+ 帧张量,并贯通 vLLM rollout 与 FSDP 更新;同时加入单次解码帧复用、时序
+ 元数据、按任务分组的批处理、异步 Ray 奖励和安全的混合引擎缓存处理。
+
+## 一次 OraRL 更新
+
+
+
+
+
+一次 OraRL 更新将可靠的标注引导与 on-policy 归一化明确分开:
+
+1. **构造样本组:** 在同一 prompt 的策略样本后加入一个序列化标注 rollout。
+2. **保持 on-policy 基线:** 仅使用策略样本的奖励估计组内基线。
+3. **引导并筛选:** 将标注与策略样本的奖励差转化为修正量,再选择符号均衡的
+ 子集执行更新。
+
+该设计直接使用任务原生标注,不需要思维链监督或解码。
+
+## Video-ORA 结果
+
+
+
+
+
+### 数据集级结果
+
+
+
+
+
+
+
+Video-ORA-9B 在不使用思维链解码的情况下,在统一的七类任务对比中取得领先。
+每行的最佳和次佳结果均已突出显示;`†` 表示该数值来自原始报告,其帧数、提示词、
+数据划分或解码设置可能不同。仅在完整覆盖该任务类别时才计算类别平均值。
+
+
+
+### 模型规模扩展
+
+
+
+
+
+### 数据规模扩展
+
+
+
+
+
+
+
+## 模型
+
+| 模型 | 基座模型 | 发布配置 | 权重 |
+| --- | --- | --- | --- |
+| **Video-ORA-9B** | Qwen3.5-9B | `orarl_9b.yaml` | [Hugging Face](https://huggingface.co/OraRL/Video-ORA-9B) |
+| **Video-ORA-4B** | Qwen3.5-4B | `orarl_4b.yaml` | Hugging Face(即将发布) |
+
+### vLLM 部署
+
+两个 Video-ORA 检查点均可通过 **vLLM 0.19.1** 直接提供兼容 OpenAI 的
+推理服务:
+
+```bash
+MODEL=OraRL/Video-ORA-9B
+
+vllm serve "$MODEL" \
+ --served-model-name Video-ORA-9B \
+ --trust-remote-code \
+ --dtype bfloat16 \
+ --tensor-parallel-size 1 \
+ --max-model-len 131072 \
+ --limit-mm-per-prompt '{"image": 1, "video": 1}'
+```
+
+多卡部署时,将 `--tensor-parallel-size` 设为 GPU 数量;显存较小时可降低
+`--max-model-len`。如需仅生成答案,请在聊天模板中设置
+`enable_thinking=false`。
+
+## 使用 OraRL
+
+本仓库围绕三个面向用户的工作流组织:
+
+1. **[环境](docs/environment_zh.md):** 安装同时覆盖自带训练运行时与评测运行时的
+ 固定 CUDA 软件栈。
+2. **[训练](docs/training_zh.md):** 准备已获许可的本地训练数据,并启动单节点或
+ 多节点 GRPO/OraRL。
+3. **[评测](docs/evaluation_zh.md):** 下载 Video-ORA 和 OraRL-Data,运行
+ smoke test 或完整论文评测。
+
+训练和评测默认仅执行 dry run;检查解析后的命令后再添加 `--run`。Checkpoint
+和评测媒体托管在 [OraRL Hugging Face 组织](https://huggingface.co/OraRL)下。
+
+## 致谢
+
+OraRL 基于 [veRL](https://github.com/volcengine/verl) 构建——这是一个采用
+HybridEngine 的高性能强化学习框架。感谢其作者和贡献者开源训练基础设施。
+
+## 许可证
+
+OraRL 源代码采用 [Apache-2.0](LICENSE) 许可证发布。数据集、模型、基准和可选
+依赖仍遵循各自的原始许可证,详情请参阅 [NOTICE](NOTICE)。
+
+## 引用
+
+如果 OraRL 对您有所帮助,欢迎为本仓库点亮 ⭐,并引用我们的
+[论文](https://arxiv.org/abs/2608.20492)。
+
+```bibtex
+@article{li2026orarl,
+ title = {Annotations as Rollouts: Efficient and Scalable
+ Reinforcement Learning for Video MLLMs},
+ author = {Li, Yunheng and Mu, Guohong and Li, Hao and
+ Qian, Shengsheng and Zhang, Dingwen and Hou, Qibin
+ and Cheng, Ming-Ming},
+ journal = {arXiv preprint arXiv:2608.20492},
+ year = {2026},
+ url = {https://arxiv.org/abs/2608.20492}
+}
+```
diff --git a/code/assets/orarl-data-scaling.gif b/code/assets/orarl-data-scaling.gif
new file mode 100644
index 0000000000000000000000000000000000000000..5d35e3acf10b0b51348257040629e851dbb908dc
--- /dev/null
+++ b/code/assets/orarl-data-scaling.gif
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f63cbb854c1c9f3867dce9300b44f01b9d6d27a018947478fb927ac9c932e57e
+size 240092
diff --git a/code/assets/orarl-hero.gif b/code/assets/orarl-hero.gif
new file mode 100644
index 0000000000000000000000000000000000000000..855733cec9ded23a97e76c63b2018f6937544a45
--- /dev/null
+++ b/code/assets/orarl-hero.gif
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c9445d37d764a433e6743df9595d497819a994d27e4d074e875f23cae44e5ef8
+size 338665
diff --git a/code/assets/orarl-method.gif b/code/assets/orarl-method.gif
new file mode 100644
index 0000000000000000000000000000000000000000..940052c9111303753fc078fa034870220f3f0f2b
--- /dev/null
+++ b/code/assets/orarl-method.gif
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8e43a40e4ea1d241e5ee230090ca39f84ce972587808b7ac11cef8ffb7c6ccd7
+size 2247059
diff --git a/code/assets/orarl-model-scaling.gif b/code/assets/orarl-model-scaling.gif
new file mode 100644
index 0000000000000000000000000000000000000000..ed2e1758b24e49cb5bf597fc6d4483aad241007f
--- /dev/null
+++ b/code/assets/orarl-model-scaling.gif
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5463916e2ec28a459bee1bed561867ad6de98b8a37ad9dee3944e43d43761595
+size 267523
diff --git a/code/assets/orarl-teaser.mp4 b/code/assets/orarl-teaser.mp4
new file mode 100644
index 0000000000000000000000000000000000000000..708816d849b8a8b02760297b81d8b48a57945fcd
--- /dev/null
+++ b/code/assets/orarl-teaser.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7915acbb2207376ef20467792c3e5b2b86b7a7a292d7f10c1bf3f44817c28e0b
+size 5901910
diff --git a/code/assets/paper-data-scaling.png b/code/assets/paper-data-scaling.png
new file mode 100644
index 0000000000000000000000000000000000000000..86daf7bf8a20a98c8db069bcb206c4860fc76c20
--- /dev/null
+++ b/code/assets/paper-data-scaling.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9f9243c616b35fea4bc5aa25b86b6c7e501d1766ba10f3db493aa1fcdf08b4ec
+size 144665
diff --git a/code/assets/paper-framework.png b/code/assets/paper-framework.png
new file mode 100644
index 0000000000000000000000000000000000000000..b0f79ae9ee820add2296e8576f0de7050a2aa550
--- /dev/null
+++ b/code/assets/paper-framework.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:aa39c05c95b1b9c6fac5615d4babe206c96c103d6c3d9c70d69bb3002a3fbe17
+size 186111
diff --git a/code/assets/paper-model-scaling.png b/code/assets/paper-model-scaling.png
new file mode 100644
index 0000000000000000000000000000000000000000..606ee8d446a3bca10228b10f7b9ca4f0dd0a6afe
--- /dev/null
+++ b/code/assets/paper-model-scaling.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c79ab0337be5ee65a4e203e8060d7515b394660409b4fd256230c5a5b32a2dc5
+size 138189
diff --git a/code/assets/paper-results.png b/code/assets/paper-results.png
new file mode 100644
index 0000000000000000000000000000000000000000..81db6a0878decdba6b49e1f8c519aab691897701
--- /dev/null
+++ b/code/assets/paper-results.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8fd05c8ff68f5a864d1d113eb5060c48e5c482e5d8ff387e0308b80a87752d0c
+size 317400
diff --git a/code/assets/video_ora_benchmark_matrix_dark.svg b/code/assets/video_ora_benchmark_matrix_dark.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ddcff7738e89bb9c993317a8f07a2209282d7f3b
--- /dev/null
+++ b/code/assets/video_ora_benchmark_matrix_dark.svg
@@ -0,0 +1,362 @@
+
+
+Benchmark comparison of Video-ORA across seven task families
+Dataset-level matrix comparing Video-ORA with Qwen3.5, InternVideo3, VideoChat3, Qwen3-VL, and LLaVA-OneVision-2, including the latest ReVSI result.
+Primary source: OraRL paper dated 2026-08-21, Tables 1–8 and 20. Dagger values: original LLaVA-OneVision-2, VideoChat3, OneThinker, and ReVSI reports.
+
+Table 1:
+Benchmark comparison of Video-ORA.
+
+Best
+
+Second-best
+† Original-report value
+
+
+Task family
+Benchmark
+Video-ORA 9B
+Video-ORA 4B
+Qwen3.5 9B
+InternVideo3 8B
+VideoChat3 4B
+Qwen3-VL 8B
+LLaVA-OV2 8B
+
+TEMPORAL
+GROUNDING
+Charades (mIoU)
+
+61.8
+
+56.8
+50.6
+53.2
+56.1
+48.3
+52.6
+
+ActivityNet (mIoU)
+
+63.6
+
+57.3
+52.2
+46.3
+54.6
+46.8
+52.4
+QVHighlights (mIoU)
+
+72.5
+
+67.5
+65.8
+59.2
+67.0
+59.4
+65.7
+
+
+
+Average
+
+66.0
+
+60.5
+56.2
+52.9
+59.2
+51.5
+56.9
+
+SPATIAL
+GROUNDING
+RefCOCO (R@0.5, 3 splits)
+
+92.9
+91.4
+
+91.6
+—
+—
+89.1†
+—
+
+RefCOCO+ (R@0.5, 3 splits)
+
+88.9
+86.3
+
+86.4
+—
+—
+84.0†
+—
+RefCOCOg (R@0.5, 2 splits)
+
+90.9
+
+89.9
+88.6
+—
+—
+86.8†
+—
+
+
+
+Average
+
+90.9
+
+89.1
+88.9
+—
+—
+87.9†
+—
+
+SEGMENTATION
+RefCOCO (cIoU)
+
+79.4
+
+76.7
+75.2
+—
+—
+73.8
+—
+
+RefCOCO+ (cIoU)
+
+72.6
+
+68.7
+65.6
+—
+—
+65.1
+—
+RefCOCOg (cIoU)
+
+75.1
+
+73.0
+70.0
+—
+—
+70.1
+—
+
+MeViS (J&F)
+
+61.3
+
+57.5
+32.1
+—
+—
+22.9
+—
+ReasonVOS (J&F)
+
+63.7
+
+63.8
+21.5
+—
+—
+19.6
+—
+
+
+
+Average
+
+70.4
+
+67.9
+52.9
+—
+—
+50.3
+—
+
+VISUAL
+TRACKING
+GOT-10k (AO)
+
+78.2
+
+67.5
+46.0
+—
+—
+33.7
+—
+
+
+SPATIAL–TEMP.
+GROUNDING
+STVG (tIoU / sIoU avg.)
+
+35.0
+
+32.4
+27.9
+—
+—
+19.5
+—
+
+VIDEO QA
+VideoMME
+
+76.7
+69.8
+71.1
+
+73.8
+70.1†
+71.4†
+71.9
+
+VideoMME-v2
+
+32.0
+26.1
+
+29.9
+27.6
+28.7†
+18.2†
+19.9
+MV-Bench
+
+72.5
+67.2
+68.2
+
+75.0
+—
+69.0†
+66.2
+
+MMVU (MC)
+
+77.0
+65.4
+
+72.5
+—
+56.4†
+65.6†
+—
+VideoHolmes
+
+65.5
+
+61.9
+50.3
+—
+—
+40.9†
+—
+
+LongVideoBench
+66.2
+60.2
+64.6
+66.8
+—
+
+68.0†
+
+66.9
+MLVU-MC
+
+77.7
+73.2
+76.7
+77.3
+—
+
+78.1†
+76.6
+
+
+
+Average
+
+66.8
+60.5
+
+61.9
+—
+—
+58.7†
+—
+
+SPATIAL
+INTELLIGENCE
+VSI-Bench
+
+73.1
+66.2
+57.9
+—
+—
+57.9
+
+70.9
+
+ReVSI (Avg., native frames)
+
+58.2
+—
+
+51.6†
+—
+—
+49.1†
+—
+MMSI-Bench
+
+37.9
+
+31.9
+31.7
+—
+—
+31.1
+—
+
+MindCube-Tiny
+
+57.4
+
+48.6
+41.2
+—
+—
+29.4
+—
+
+
+Paper Avg. (3 datasets)
+
+56.1
+
+48.9
+43.6
+—
+—
+39.5
+—
+
+† Original-report value; frame, prompt, split, or decoding settings may differ.
+OraRL paper (Aug. 21, 2026), Tables 1–8 & 20. Paper Average excludes ReVSI.
+↑ higher is better
+
diff --git a/code/assets/video_ora_benchmark_matrix_light.svg b/code/assets/video_ora_benchmark_matrix_light.svg
new file mode 100644
index 0000000000000000000000000000000000000000..cc0e19d95333bdc5e1d655803a03a4d5e3ae254d
--- /dev/null
+++ b/code/assets/video_ora_benchmark_matrix_light.svg
@@ -0,0 +1,362 @@
+
+
+Benchmark comparison of Video-ORA across seven task families
+Dataset-level matrix comparing Video-ORA with Qwen3.5, InternVideo3, VideoChat3, Qwen3-VL, and LLaVA-OneVision-2, including the latest ReVSI result.
+Primary source: OraRL paper dated 2026-08-21, Tables 1–8 and 20. Dagger values: original LLaVA-OneVision-2, VideoChat3, OneThinker, and ReVSI reports.
+
+Table 1:
+Benchmark comparison of Video-ORA.
+
+Best
+
+Second-best
+† Original-report value
+
+
+Task family
+Benchmark
+Video-ORA 9B
+Video-ORA 4B
+Qwen3.5 9B
+InternVideo3 8B
+VideoChat3 4B
+Qwen3-VL 8B
+LLaVA-OV2 8B
+
+TEMPORAL
+GROUNDING
+Charades (mIoU)
+
+61.8
+
+56.8
+50.6
+53.2
+56.1
+48.3
+52.6
+
+ActivityNet (mIoU)
+
+63.6
+
+57.3
+52.2
+46.3
+54.6
+46.8
+52.4
+QVHighlights (mIoU)
+
+72.5
+
+67.5
+65.8
+59.2
+67.0
+59.4
+65.7
+
+
+
+Average
+
+66.0
+
+60.5
+56.2
+52.9
+59.2
+51.5
+56.9
+
+SPATIAL
+GROUNDING
+RefCOCO (R@0.5, 3 splits)
+
+92.9
+91.4
+
+91.6
+—
+—
+89.1†
+—
+
+RefCOCO+ (R@0.5, 3 splits)
+
+88.9
+86.3
+
+86.4
+—
+—
+84.0†
+—
+RefCOCOg (R@0.5, 2 splits)
+
+90.9
+
+89.9
+88.6
+—
+—
+86.8†
+—
+
+
+
+Average
+
+90.9
+
+89.1
+88.9
+—
+—
+87.9†
+—
+
+SEGMENTATION
+RefCOCO (cIoU)
+
+79.4
+
+76.7
+75.2
+—
+—
+73.8
+—
+
+RefCOCO+ (cIoU)
+
+72.6
+
+68.7
+65.6
+—
+—
+65.1
+—
+RefCOCOg (cIoU)
+
+75.1
+
+73.0
+70.0
+—
+—
+70.1
+—
+
+MeViS (J&F)
+
+61.3
+
+57.5
+32.1
+—
+—
+22.9
+—
+ReasonVOS (J&F)
+
+63.7
+
+63.8
+21.5
+—
+—
+19.6
+—
+
+
+
+Average
+
+70.4
+
+67.9
+52.9
+—
+—
+50.3
+—
+
+VISUAL
+TRACKING
+GOT-10k (AO)
+
+78.2
+
+67.5
+46.0
+—
+—
+33.7
+—
+
+
+SPATIAL–TEMP.
+GROUNDING
+STVG (tIoU / sIoU avg.)
+
+35.0
+
+32.4
+27.9
+—
+—
+19.5
+—
+
+VIDEO QA
+VideoMME
+
+76.7
+69.8
+71.1
+
+73.8
+70.1†
+71.4†
+71.9
+
+VideoMME-v2
+
+32.0
+26.1
+
+29.9
+27.6
+28.7†
+18.2†
+19.9
+MV-Bench
+
+72.5
+67.2
+68.2
+
+75.0
+—
+69.0†
+66.2
+
+MMVU (MC)
+
+77.0
+65.4
+
+72.5
+—
+56.4†
+65.6†
+—
+VideoHolmes
+
+65.5
+
+61.9
+50.3
+—
+—
+40.9†
+—
+
+LongVideoBench
+66.2
+60.2
+64.6
+66.8
+—
+
+68.0†
+
+66.9
+MLVU-MC
+
+77.7
+73.2
+76.7
+77.3
+—
+
+78.1†
+76.6
+
+
+
+Average
+
+66.8
+60.5
+
+61.9
+—
+—
+58.7†
+—
+
+SPATIAL
+INTELLIGENCE
+VSI-Bench
+
+73.1
+66.2
+57.9
+—
+—
+57.9
+
+70.9
+
+ReVSI (Avg., native frames)
+
+58.2
+—
+
+51.6†
+—
+—
+49.1†
+—
+MMSI-Bench
+
+37.9
+
+31.9
+31.7
+—
+—
+31.1
+—
+
+MindCube-Tiny
+
+57.4
+
+48.6
+41.2
+—
+—
+29.4
+—
+
+
+Paper Avg. (3 datasets)
+
+56.1
+
+48.9
+43.6
+—
+—
+39.5
+—
+
+† Original-report value; frame, prompt, split, or decoding settings may differ.
+OraRL paper (Aug. 21, 2026), Tables 1–8 & 20. Paper Average excludes ReVSI.
+↑ higher is better
+
diff --git a/code/configs/data_sources.example.yaml b/code/configs/data_sources.example.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..c04f47416a055e7d6e996f81635f59d2a3e4e8cd
--- /dev/null
+++ b/code/configs/data_sources.example.yaml
@@ -0,0 +1,90 @@
+# Copy this file, then replace every ../local_data placeholder.
+#
+# OraRL never downloads data or media. You must obtain each dataset under its
+# license, keep the required attribution, and point input/media_root at your
+# own local, licensed copy. The optional license and url values are audit
+# metadata only and are never opened by the builder.
+#
+# The seven quotas reproduce the final paper mixture: 100,032 train prompts
+# (1,563 batches of 64), with 62,656 structured prompts and 37,376 answer-only
+# prompts. The canary is
+# additional held-out data and is not subtracted from the train target.
+version: 1
+seed: 42
+target: 100032
+canary_size: 512
+max_prompts_per_media: 2
+require_media: true
+allow_shortfall: false
+
+# Supply JSON/JSONL benchmark records before a release build. Any candidate
+# sharing a normalized prompt identity or media identity is removed.
+benchmark_excludes:
+ - ../local_data/exclusions/public_benchmarks.jsonl
+
+sources:
+ - name: temporal_grounding_train
+ input: ../local_data/annotations/temporal_grounding.jsonl
+ task: temporal grounding
+ family: temporal
+ quota: 20096
+ media_root: ../local_data/media/temporal
+ license: REPLACE_WITH_DATASET_LICENSE
+ url: REPLACE_WITH_DATASET_HOME_PAGE
+
+ - name: tracking_train
+ input: ../local_data/annotations/tracking.jsonl
+ task: tracking
+ family: tracking
+ quota: 13952
+ media_root: ../local_data/media/tracking
+ license: REPLACE_WITH_DATASET_LICENSE
+ url: REPLACE_WITH_DATASET_HOME_PAGE
+
+ - name: segmentation_train
+ input: ../local_data/annotations/segmentation.jsonl
+ task: segmentation
+ family: segmentation
+ quota: 12032
+ media_root: ../local_data/media/segmentation
+ license: REPLACE_WITH_DATASET_LICENSE
+ url: REPLACE_WITH_DATASET_HOME_PAGE
+
+ - name: spatial_grounding_train
+ input: ../local_data/annotations/spatial_grounding.jsonl
+ task: spatial grounding
+ family: spatial
+ quota: 7040
+ media_root: ../local_data/media/spatial
+ license: REPLACE_WITH_DATASET_LICENSE
+ url: REPLACE_WITH_DATASET_HOME_PAGE
+
+ - name: spatial_temporal_grounding_train
+ input: ../local_data/annotations/spatial_temporal_grounding.jsonl
+ task: spatial-temporal grounding
+ family: stvg
+ quota: 9536
+ media_root: ../local_data/media/spatial_temporal
+ license: REPLACE_WITH_DATASET_LICENSE
+ url: REPLACE_WITH_DATASET_HOME_PAGE
+
+ - name: video_qa_train
+ input: ../local_data/annotations/video_qa.jsonl
+ task: video_qa_mc
+ family: video_qa
+ quota: 20288
+ media_root: ../local_data/media/video_qa
+ license: REPLACE_WITH_DATASET_LICENSE
+ url: REPLACE_WITH_DATASET_HOME_PAGE
+
+ - name: spatial_intelligence_train
+ input: ../local_data/annotations/spatial_intelligence.jsonl
+ task: spatial intelligence
+ family: spatial_intelligence
+ # Keep subtype labels such as object_counting and object_rel_direction;
+ # the reward adapter uses them to select the official scoring rule.
+ preserve_problem_type: true
+ quota: 17088
+ media_root: ../local_data/media/spatial_intelligence
+ license: REPLACE_WITH_DATASET_LICENSE
+ url: REPLACE_WITH_DATASET_HOME_PAGE
diff --git a/code/configs/grpo_4b.yaml b/code/configs/grpo_4b.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..a7c8b8190758cefddf156b95fa5dd6bcdf8e58de
--- /dev/null
+++ b/code/configs/grpo_4b.yaml
@@ -0,0 +1,139 @@
+# Complete 4B GRPO baseline. Required paths are supplied by the CLI or environment.
+data:
+ train_files: ${oc.env:ORARL_TRAIN_DATA}
+ val_files: ${oc.env:ORARL_VAL_DATA}
+ prompt_key: problem
+ answer_key: answer
+ image_key: images
+ video_key: videos
+ image_dir: ${oc.env:ORARL_MEDIA_ROOT,null}
+ dataloader_num_workers: ${oc.env:ORARL_DATALOADER_WORKERS,4}
+ video_max_pixels: ${oc.env:ORARL_VIDEO_MAX_PIXELS,786432}
+ video_min_pixels: ${oc.env:ORARL_VIDEO_MIN_PIXELS,4096}
+ video_max_frames: ${oc.env:ORARL_VIDEO_MAX_FRAMES,128}
+ video_fps: ${oc.env:ORARL_VIDEO_FPS,2}
+ video_total_pixels: ${oc.env:ORARL_VIDEO_TOTAL_PIXELS,8388608}
+ min_pixels: ${oc.env:ORARL_IMAGE_MIN_PIXELS,4096}
+ image_max_pixels: ${oc.env:ORARL_IMAGE_MAX_PIXELS,1048576}
+ use_preprocessed_videos: false
+ video_source_mode: ${oc.env:ORARL_VIDEO_SOURCE_MODE,realtime_only}
+ preprocessed_video_dir: ${oc.env:ORARL_PREPROCESSED_DIR,null}
+ inline_video_tensors: ${oc.env:ORARL_INLINE_VIDEO_TENSORS,false}
+ max_prompt_length: ${oc.env:ORARL_MAX_PROMPT_LENGTH,16384}
+ max_response_length: ${oc.env:ORARL_MAX_RESPONSE_LENGTH,4096}
+ rollout_batch_size: ${oc.env:ORARL_ROLLOUT_BATCH_SIZE,64}
+ mini_rollout_batch_size: null
+ val_batch_size: ${oc.env:ORARL_VAL_BATCH_SIZE,16}
+ format_prompt: null
+ override_chat_template: null
+ enable_thinking: ${oc.env:ORARL_ENABLE_THINKING,false}
+ response_prefix: ""
+ shuffle: true
+ seed: ${oc.env:ORARL_SEED,42}
+ group_by_task: true
+ group_by_task_key: problem_type
+ filter_overlong_prompts: false
+
+algorithm:
+ name: grpo
+ adv_estimator: grpo
+ scale_rewards: true
+ disable_kl: true
+ use_kl_loss: false
+ kl_penalty: low_var_kl
+ kl_coef: 0.0
+
+worker:
+ actor:
+ global_batch_size: ${oc.env:ORARL_GLOBAL_BATCH_SIZE,64}
+ micro_batch_size_per_device_for_update: ${oc.env:ORARL_UPDATE_MICRO_BATCH,2}
+ micro_batch_size_per_device_for_experience: ${oc.env:ORARL_EXPERIENCE_MICRO_BATCH,1}
+ ppo_epochs: 1
+ max_grad_norm: 1.0
+ clip_ratio_low: 0.2
+ clip_ratio_high: 0.2
+ loss_avg_mode: token
+ padding_free: true
+ dynamic_batching: true
+ max_token_len_per_gpu: ${oc.env:ORARL_MAX_TOKEN_LEN_PER_GPU,24576}
+ ulysses_size: 1
+ use_torch_compile: false
+ model:
+ model_path: ${oc.env:ORARL_MODEL_PATH}
+ tokenizer_path: ${oc.env:ORARL_TOKENIZER_PATH,${oc.env:ORARL_MODEL_PATH}}
+ enable_gradient_checkpointing: true
+ trust_remote_code: false
+ freeze_vision_tower: true
+ train_vision_merger: false
+ optim:
+ lr: ${oc.env:ORARL_LEARNING_RATE,2.0e-6}
+ weight_decay: 0.0
+ strategy: adamw
+ lr_warmup_ratio: 0.0
+ lr_scheduler_type: constant
+ fsdp:
+ torch_dtype: bf16
+ enable_full_shard: true
+ enable_cpu_offload: false
+ enable_rank0_init: true
+ use_orig_params: false
+ offload:
+ offload_params: false
+ offload_optimizer: false
+
+ rollout:
+ name: ${oc.env:ORARL_ROLLOUT_BACKEND,vllm}
+ n: 8
+ seed: ${oc.env:ORARL_SEED,42}
+ temperature: 1.0
+ top_p: 0.85
+ limit_images: 10
+ gpu_memory_utilization: 0.5
+ enforce_eager: false
+ enable_chunked_prefill: true
+ tensor_parallel_size: 1
+ max_num_batched_tokens: 32768
+ disable_tqdm: true
+ calculate_log_probs: false
+ collect_seq_logprob_for_filter: false
+ val_override_config:
+ temperature: 0.7
+ top_p: 0.95
+ n: 1
+
+ ref:
+ fsdp:
+ torch_dtype: bf16
+ enable_full_shard: true
+ enable_cpu_offload: false
+ enable_rank0_init: true
+ offload:
+ offload_params: false
+
+ reward:
+ reward_function: orarl.rewards:compute_score
+ reward_function_kwargs: {}
+
+trainer:
+ total_epochs: 1
+ max_steps: null
+ project_name: OraRL
+ experiment_name: grpo-4b
+ logger: ["console"]
+ nnodes: 1
+ n_gpus_per_node: 8
+ val_freq: -1
+ val_before_train: false
+ val_only: false
+ val_generations_to_log: 0
+ save_freq: 50
+ save_limit: 3
+ save_model_only: false
+ keep_optim_only_latest: true
+ save_checkpoint_path: ${oc.env:ORARL_OUTPUT_DIR}
+ load_checkpoint_path: null
+ find_last_checkpoint: false
+ keep_best_train_ckpt: false
+ best_train_metric_key: reward/overall
+ best_train_smooth_window: 5
+ best_train_min_step: 10
diff --git a/code/configs/grpo_9b.yaml b/code/configs/grpo_9b.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..a6cc6be5e47836a2658a8a15d8f03112a0654446
--- /dev/null
+++ b/code/configs/grpo_9b.yaml
@@ -0,0 +1,139 @@
+# Complete 9B GRPO baseline. Required paths are supplied by the CLI or environment.
+data:
+ train_files: ${oc.env:ORARL_TRAIN_DATA}
+ val_files: ${oc.env:ORARL_VAL_DATA}
+ prompt_key: problem
+ answer_key: answer
+ image_key: images
+ video_key: videos
+ image_dir: ${oc.env:ORARL_MEDIA_ROOT,null}
+ dataloader_num_workers: ${oc.env:ORARL_DATALOADER_WORKERS,4}
+ video_max_pixels: ${oc.env:ORARL_VIDEO_MAX_PIXELS,786432}
+ video_min_pixels: ${oc.env:ORARL_VIDEO_MIN_PIXELS,4096}
+ video_max_frames: ${oc.env:ORARL_VIDEO_MAX_FRAMES,128}
+ video_fps: ${oc.env:ORARL_VIDEO_FPS,2}
+ video_total_pixels: ${oc.env:ORARL_VIDEO_TOTAL_PIXELS,8388608}
+ min_pixels: ${oc.env:ORARL_IMAGE_MIN_PIXELS,4096}
+ image_max_pixels: ${oc.env:ORARL_IMAGE_MAX_PIXELS,1048576}
+ use_preprocessed_videos: false
+ video_source_mode: ${oc.env:ORARL_VIDEO_SOURCE_MODE,realtime_only}
+ preprocessed_video_dir: ${oc.env:ORARL_PREPROCESSED_DIR,null}
+ inline_video_tensors: ${oc.env:ORARL_INLINE_VIDEO_TENSORS,false}
+ max_prompt_length: ${oc.env:ORARL_MAX_PROMPT_LENGTH,24576}
+ max_response_length: ${oc.env:ORARL_MAX_RESPONSE_LENGTH,4096}
+ rollout_batch_size: ${oc.env:ORARL_ROLLOUT_BATCH_SIZE,64}
+ mini_rollout_batch_size: null
+ val_batch_size: ${oc.env:ORARL_VAL_BATCH_SIZE,16}
+ format_prompt: null
+ override_chat_template: null
+ enable_thinking: ${oc.env:ORARL_ENABLE_THINKING,false}
+ response_prefix: ""
+ shuffle: true
+ seed: ${oc.env:ORARL_SEED,42}
+ group_by_task: true
+ group_by_task_key: problem_type
+ filter_overlong_prompts: false
+
+algorithm:
+ name: grpo
+ adv_estimator: grpo
+ scale_rewards: true
+ disable_kl: true
+ use_kl_loss: false
+ kl_penalty: low_var_kl
+ kl_coef: 0.0
+
+worker:
+ actor:
+ global_batch_size: ${oc.env:ORARL_GLOBAL_BATCH_SIZE,64}
+ micro_batch_size_per_device_for_update: ${oc.env:ORARL_UPDATE_MICRO_BATCH,1}
+ micro_batch_size_per_device_for_experience: ${oc.env:ORARL_EXPERIENCE_MICRO_BATCH,1}
+ ppo_epochs: 1
+ max_grad_norm: 1.0
+ clip_ratio_low: 0.2
+ clip_ratio_high: 0.2
+ loss_avg_mode: token
+ padding_free: true
+ dynamic_batching: true
+ max_token_len_per_gpu: ${oc.env:ORARL_MAX_TOKEN_LEN_PER_GPU,32768}
+ ulysses_size: 1
+ use_torch_compile: false
+ model:
+ model_path: ${oc.env:ORARL_MODEL_PATH}
+ tokenizer_path: ${oc.env:ORARL_TOKENIZER_PATH,${oc.env:ORARL_MODEL_PATH}}
+ enable_gradient_checkpointing: true
+ trust_remote_code: false
+ freeze_vision_tower: true
+ train_vision_merger: false
+ optim:
+ lr: ${oc.env:ORARL_LEARNING_RATE,1.0e-6}
+ weight_decay: 0.0
+ strategy: adamw
+ lr_warmup_ratio: 0.0
+ lr_scheduler_type: constant
+ fsdp:
+ torch_dtype: bf16
+ enable_full_shard: true
+ enable_cpu_offload: false
+ enable_rank0_init: true
+ use_orig_params: false
+ offload:
+ offload_params: false
+ offload_optimizer: false
+
+ rollout:
+ name: ${oc.env:ORARL_ROLLOUT_BACKEND,vllm}
+ n: 8
+ seed: ${oc.env:ORARL_SEED,42}
+ temperature: 1.0
+ top_p: 0.85
+ limit_images: 10
+ gpu_memory_utilization: 0.45
+ enforce_eager: false
+ enable_chunked_prefill: true
+ tensor_parallel_size: 1
+ max_num_batched_tokens: 32768
+ disable_tqdm: true
+ calculate_log_probs: false
+ collect_seq_logprob_for_filter: false
+ val_override_config:
+ temperature: 0.7
+ top_p: 0.95
+ n: 1
+
+ ref:
+ fsdp:
+ torch_dtype: bf16
+ enable_full_shard: true
+ enable_cpu_offload: false
+ enable_rank0_init: true
+ offload:
+ offload_params: false
+
+ reward:
+ reward_function: orarl.rewards:compute_score
+ reward_function_kwargs: {}
+
+trainer:
+ total_epochs: 1
+ max_steps: null
+ project_name: OraRL
+ experiment_name: grpo-9b
+ logger: ["console"]
+ nnodes: 1
+ n_gpus_per_node: 8
+ val_freq: -1
+ val_before_train: false
+ val_only: false
+ val_generations_to_log: 0
+ save_freq: 50
+ save_limit: 3
+ save_model_only: false
+ keep_optim_only_latest: true
+ save_checkpoint_path: ${oc.env:ORARL_OUTPUT_DIR}
+ load_checkpoint_path: null
+ find_last_checkpoint: false
+ keep_best_train_ckpt: false
+ best_train_metric_key: reward/overall
+ best_train_smooth_window: 5
+ best_train_min_step: 10
diff --git a/code/configs/orarl_4b.yaml b/code/configs/orarl_4b.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..c11299402ec34940d976dbaef352327a7c5ca856
--- /dev/null
+++ b/code/configs/orarl_4b.yaml
@@ -0,0 +1,162 @@
+# Complete 4B OraRL recipe. Required paths are supplied by the CLI or environment.
+data:
+ train_files: ${oc.env:ORARL_TRAIN_DATA}
+ val_files: ${oc.env:ORARL_VAL_DATA}
+ prompt_key: problem
+ answer_key: answer
+ image_key: images
+ video_key: videos
+ image_dir: ${oc.env:ORARL_MEDIA_ROOT,null}
+ dataloader_num_workers: ${oc.env:ORARL_DATALOADER_WORKERS,4}
+ video_max_pixels: ${oc.env:ORARL_VIDEO_MAX_PIXELS,786432}
+ video_min_pixels: ${oc.env:ORARL_VIDEO_MIN_PIXELS,4096}
+ video_max_frames: ${oc.env:ORARL_VIDEO_MAX_FRAMES,128}
+ video_fps: ${oc.env:ORARL_VIDEO_FPS,2}
+ video_total_pixels: ${oc.env:ORARL_VIDEO_TOTAL_PIXELS,8388608}
+ min_pixels: ${oc.env:ORARL_IMAGE_MIN_PIXELS,4096}
+ image_max_pixels: ${oc.env:ORARL_IMAGE_MAX_PIXELS,1048576}
+ use_preprocessed_videos: false
+ video_source_mode: ${oc.env:ORARL_VIDEO_SOURCE_MODE,realtime_only}
+ preprocessed_video_dir: ${oc.env:ORARL_PREPROCESSED_DIR,null}
+ inline_video_tensors: ${oc.env:ORARL_INLINE_VIDEO_TENSORS,false}
+ max_prompt_length: ${oc.env:ORARL_MAX_PROMPT_LENGTH,16384}
+ max_response_length: ${oc.env:ORARL_MAX_RESPONSE_LENGTH,4096}
+ rollout_batch_size: ${oc.env:ORARL_ROLLOUT_BATCH_SIZE,64}
+ mini_rollout_batch_size: null
+ val_batch_size: ${oc.env:ORARL_VAL_BATCH_SIZE,16}
+ format_prompt: null
+ override_chat_template: null
+ enable_thinking: ${oc.env:ORARL_ENABLE_THINKING,false}
+ response_prefix: ""
+ shuffle: true
+ seed: ${oc.env:ORARL_SEED,42}
+ group_by_task: true
+ group_by_task_key: problem_type
+ filter_overlong_prompts: false
+
+algorithm:
+ name: orarl
+ adv_estimator: grpo
+ scale_rewards: false
+ oracle_injection: true
+ oracle_injection_mode: append
+ oracle_builder: orarl.rewards:build_oracle_response_from_ground_truth
+ oracle_log_exclude: true
+ directional_gain: true
+ directional_gain_gamma: 0.25
+ directional_gain_positive_only: true
+ directional_gain_recenter: true
+ detached_oracle_advantage: true
+ detached_oracle_advantage_scale: 2.0
+ detached_oracle_use_directional_gain: false
+ detached_oracle_match_best_ratio: 1.2
+ detached_oracle_match_best_min: 0.05
+ detached_oracle_match_best_max: 1.0
+ oracle_reward_gate_beta: 2.0
+ selection_prune_ratio: 0.5
+ selection_keep_oracle: true
+ selection_positive_quota: 1
+ selection_negative_quota: 2
+ selection_strict_sign_balance: true
+ post_selection_recenter: true
+ post_selection_rms_match: true
+ post_selection_rms_min_scale: 0.25
+ disable_kl: true
+ use_kl_loss: false
+ kl_penalty: low_var_kl
+ kl_coef: 0.0
+
+worker:
+ actor:
+ global_batch_size: ${oc.env:ORARL_GLOBAL_BATCH_SIZE,64}
+ micro_batch_size_per_device_for_update: ${oc.env:ORARL_UPDATE_MICRO_BATCH,2}
+ micro_batch_size_per_device_for_experience: ${oc.env:ORARL_EXPERIENCE_MICRO_BATCH,1}
+ ppo_epochs: 1
+ max_grad_norm: 1.0
+ clip_ratio_low: 0.2
+ clip_ratio_high: 0.2
+ loss_avg_mode: token
+ padding_free: true
+ dynamic_batching: true
+ max_token_len_per_gpu: ${oc.env:ORARL_MAX_TOKEN_LEN_PER_GPU,24576}
+ ulysses_size: 1
+ use_torch_compile: false
+ model:
+ model_path: ${oc.env:ORARL_MODEL_PATH}
+ tokenizer_path: ${oc.env:ORARL_TOKENIZER_PATH,${oc.env:ORARL_MODEL_PATH}}
+ enable_gradient_checkpointing: true
+ trust_remote_code: false
+ freeze_vision_tower: true
+ train_vision_merger: false
+ optim:
+ lr: ${oc.env:ORARL_LEARNING_RATE,2.0e-6}
+ weight_decay: 0.0
+ strategy: adamw
+ lr_warmup_ratio: 0.0
+ lr_scheduler_type: constant
+ fsdp:
+ torch_dtype: bf16
+ enable_full_shard: true
+ enable_cpu_offload: false
+ enable_rank0_init: true
+ use_orig_params: false
+ offload:
+ offload_params: false
+ offload_optimizer: false
+
+ rollout:
+ name: ${oc.env:ORARL_ROLLOUT_BACKEND,vllm}
+ n: 8
+ seed: ${oc.env:ORARL_SEED,42}
+ temperature: 1.0
+ top_p: 0.85
+ limit_images: 10
+ gpu_memory_utilization: 0.5
+ enforce_eager: false
+ enable_chunked_prefill: true
+ tensor_parallel_size: 1
+ max_num_batched_tokens: 32768
+ disable_tqdm: true
+ calculate_log_probs: false
+ collect_seq_logprob_for_filter: false
+ val_override_config:
+ temperature: 0.7
+ top_p: 0.95
+ n: 1
+
+ ref:
+ fsdp:
+ torch_dtype: bf16
+ enable_full_shard: true
+ enable_cpu_offload: false
+ enable_rank0_init: true
+ offload:
+ offload_params: false
+
+ reward:
+ reward_function: orarl.rewards:compute_score
+ reward_function_kwargs: {}
+
+trainer:
+ total_epochs: 1
+ max_steps: null
+ project_name: OraRL
+ experiment_name: orarl-4b
+ logger: ["console"]
+ nnodes: 1
+ n_gpus_per_node: 8
+ val_freq: -1
+ val_before_train: false
+ val_only: false
+ val_generations_to_log: 0
+ save_freq: 50
+ save_limit: 3
+ save_model_only: false
+ keep_optim_only_latest: true
+ save_checkpoint_path: ${oc.env:ORARL_OUTPUT_DIR}
+ load_checkpoint_path: null
+ find_last_checkpoint: false
+ keep_best_train_ckpt: false
+ best_train_metric_key: reward/overall
+ best_train_smooth_window: 5
+ best_train_min_step: 10
diff --git a/code/configs/orarl_9b.yaml b/code/configs/orarl_9b.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..4da8dcba7ce449c5880daf80c96dac4a8043379f
--- /dev/null
+++ b/code/configs/orarl_9b.yaml
@@ -0,0 +1,162 @@
+# Complete 9B OraRL recipe. Required paths are supplied by the CLI or environment.
+data:
+ train_files: ${oc.env:ORARL_TRAIN_DATA}
+ val_files: ${oc.env:ORARL_VAL_DATA}
+ prompt_key: problem
+ answer_key: answer
+ image_key: images
+ video_key: videos
+ image_dir: ${oc.env:ORARL_MEDIA_ROOT,null}
+ dataloader_num_workers: ${oc.env:ORARL_DATALOADER_WORKERS,4}
+ video_max_pixels: ${oc.env:ORARL_VIDEO_MAX_PIXELS,786432}
+ video_min_pixels: ${oc.env:ORARL_VIDEO_MIN_PIXELS,4096}
+ video_max_frames: ${oc.env:ORARL_VIDEO_MAX_FRAMES,128}
+ video_fps: ${oc.env:ORARL_VIDEO_FPS,2}
+ video_total_pixels: ${oc.env:ORARL_VIDEO_TOTAL_PIXELS,8388608}
+ min_pixels: ${oc.env:ORARL_IMAGE_MIN_PIXELS,4096}
+ image_max_pixels: ${oc.env:ORARL_IMAGE_MAX_PIXELS,1048576}
+ use_preprocessed_videos: false
+ video_source_mode: ${oc.env:ORARL_VIDEO_SOURCE_MODE,realtime_only}
+ preprocessed_video_dir: ${oc.env:ORARL_PREPROCESSED_DIR,null}
+ inline_video_tensors: ${oc.env:ORARL_INLINE_VIDEO_TENSORS,false}
+ max_prompt_length: ${oc.env:ORARL_MAX_PROMPT_LENGTH,24576}
+ max_response_length: ${oc.env:ORARL_MAX_RESPONSE_LENGTH,4096}
+ rollout_batch_size: ${oc.env:ORARL_ROLLOUT_BATCH_SIZE,64}
+ mini_rollout_batch_size: null
+ val_batch_size: ${oc.env:ORARL_VAL_BATCH_SIZE,16}
+ format_prompt: null
+ override_chat_template: null
+ enable_thinking: ${oc.env:ORARL_ENABLE_THINKING,false}
+ response_prefix: ""
+ shuffle: true
+ seed: ${oc.env:ORARL_SEED,42}
+ group_by_task: true
+ group_by_task_key: problem_type
+ filter_overlong_prompts: false
+
+algorithm:
+ name: orarl
+ adv_estimator: grpo
+ scale_rewards: false
+ oracle_injection: true
+ oracle_injection_mode: append
+ oracle_builder: orarl.rewards:build_oracle_response_from_ground_truth
+ oracle_log_exclude: true
+ directional_gain: true
+ directional_gain_gamma: 0.25
+ directional_gain_positive_only: true
+ directional_gain_recenter: true
+ detached_oracle_advantage: true
+ detached_oracle_advantage_scale: 2.0
+ detached_oracle_use_directional_gain: false
+ detached_oracle_match_best_ratio: 1.2
+ detached_oracle_match_best_min: 0.05
+ detached_oracle_match_best_max: 1.0
+ oracle_reward_gate_beta: 2.0
+ selection_prune_ratio: 0.5
+ selection_keep_oracle: true
+ selection_positive_quota: 1
+ selection_negative_quota: 2
+ selection_strict_sign_balance: true
+ post_selection_recenter: true
+ post_selection_rms_match: true
+ post_selection_rms_min_scale: 0.25
+ disable_kl: true
+ use_kl_loss: false
+ kl_penalty: low_var_kl
+ kl_coef: 0.0
+
+worker:
+ actor:
+ global_batch_size: ${oc.env:ORARL_GLOBAL_BATCH_SIZE,64}
+ micro_batch_size_per_device_for_update: ${oc.env:ORARL_UPDATE_MICRO_BATCH,1}
+ micro_batch_size_per_device_for_experience: ${oc.env:ORARL_EXPERIENCE_MICRO_BATCH,1}
+ ppo_epochs: 1
+ max_grad_norm: 1.0
+ clip_ratio_low: 0.2
+ clip_ratio_high: 0.2
+ loss_avg_mode: token
+ padding_free: true
+ dynamic_batching: true
+ max_token_len_per_gpu: ${oc.env:ORARL_MAX_TOKEN_LEN_PER_GPU,32768}
+ ulysses_size: 1
+ use_torch_compile: false
+ model:
+ model_path: ${oc.env:ORARL_MODEL_PATH}
+ tokenizer_path: ${oc.env:ORARL_TOKENIZER_PATH,${oc.env:ORARL_MODEL_PATH}}
+ enable_gradient_checkpointing: true
+ trust_remote_code: false
+ freeze_vision_tower: true
+ train_vision_merger: false
+ optim:
+ lr: ${oc.env:ORARL_LEARNING_RATE,1.0e-6}
+ weight_decay: 0.0
+ strategy: adamw
+ lr_warmup_ratio: 0.0
+ lr_scheduler_type: constant
+ fsdp:
+ torch_dtype: bf16
+ enable_full_shard: true
+ enable_cpu_offload: false
+ enable_rank0_init: true
+ use_orig_params: false
+ offload:
+ offload_params: false
+ offload_optimizer: false
+
+ rollout:
+ name: ${oc.env:ORARL_ROLLOUT_BACKEND,vllm}
+ n: 8
+ seed: ${oc.env:ORARL_SEED,42}
+ temperature: 1.0
+ top_p: 0.85
+ limit_images: 10
+ gpu_memory_utilization: 0.45
+ enforce_eager: false
+ enable_chunked_prefill: true
+ tensor_parallel_size: 1
+ max_num_batched_tokens: 32768
+ disable_tqdm: true
+ calculate_log_probs: false
+ collect_seq_logprob_for_filter: false
+ val_override_config:
+ temperature: 0.7
+ top_p: 0.95
+ n: 1
+
+ ref:
+ fsdp:
+ torch_dtype: bf16
+ enable_full_shard: true
+ enable_cpu_offload: false
+ enable_rank0_init: true
+ offload:
+ offload_params: false
+
+ reward:
+ reward_function: orarl.rewards:compute_score
+ reward_function_kwargs: {}
+
+trainer:
+ total_epochs: 1
+ max_steps: null
+ project_name: OraRL
+ experiment_name: orarl-9b
+ logger: ["console"]
+ nnodes: 1
+ n_gpus_per_node: 8
+ val_freq: -1
+ val_before_train: false
+ val_only: false
+ val_generations_to_log: 0
+ save_freq: 50
+ save_limit: 3
+ save_model_only: false
+ keep_optim_only_latest: true
+ save_checkpoint_path: ${oc.env:ORARL_OUTPUT_DIR}
+ load_checkpoint_path: null
+ find_last_checkpoint: false
+ keep_best_train_ckpt: false
+ best_train_metric_key: reward/overall
+ best_train_smooth_window: 5
+ best_train_min_step: 10
diff --git a/code/data/eval/README.md b/code/data/eval/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..102e06d9fa8b4fa724cc00da1ac31f84f93b4ab0
--- /dev/null
+++ b/code/data/eval/README.md
@@ -0,0 +1,25 @@
+# Evaluation profiles
+
+This directory contains only the small, reviewable `datasets.jsonl` manifest
+that pins the paper-suite splits, prompts, preprocessing settings, parsers, and
+metrics.
+
+Evaluation annotations and raw media are hosted separately at
+[`OraRL/OraRL-Data`](https://huggingface.co/datasets/OraRL/OraRL-Data).
+Model checkpoints are hosted under the
+[`OraRL`](https://huggingface.co/OraRL) organization. They are intentionally not
+duplicated in this Git repository.
+
+Use the Hugging Face dataset ID or a complete local snapshot when evaluating:
+
+```bash
+orarl-eval \
+ --model /path/to/Video-ORA-9B \
+ --tasks paper \
+ --dataset OraRL/OraRL-Data \
+ --summary ./evaluation.json \
+ --run
+```
+
+The manifest in this directory is release metadata, not a standalone dataset
+root; it does not include `assets.jsonl` or media payloads.
diff --git a/code/data/eval/datasets.jsonl b/code/data/eval/datasets.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..a286784ba3ae7b0d304f7b00b2316c265526fb7a
--- /dev/null
+++ b/code/data/eval/datasets.jsonl
@@ -0,0 +1,29 @@
+{"annotation_path":"annotations/video_qa/longvideobench/val.jsonl","artifact_paths":[],"benchmark":"longvideobench","evaluation":{"metric_profile":"accuracy","parser_profile":"multiple_choice","prompt_profile":"longvideobench_default"},"expected_count":1337,"family":"video_qa","legacy_environment":{"LONGVIDEOBENCH_BATCH_SIZE":1,"LONGVIDEOBENCH_ENABLE_THINKING":false,"LONGVIDEOBENCH_FPS":2,"LONGVIDEOBENCH_GPU_MEMORY_UTILIZATION":0.9,"LONGVIDEOBENCH_MAX_FRAMES":384,"LONGVIDEOBENCH_MAX_MODEL_LEN":65536,"LONGVIDEOBENCH_MAX_NEW_TOKENS":128,"LONGVIDEOBENCH_MAX_NUM_BATCHED_TOKENS":65536,"LONGVIDEOBENCH_MIN_P":0.0,"LONGVIDEOBENCH_PRESENCE_PENALTY":0.0,"LONGVIDEOBENCH_PROMPT_MODE":"default","LONGVIDEOBENCH_TEMPERATURE":0.0,"LONGVIDEOBENCH_TOP_K":-1,"LONGVIDEOBENCH_TOP_P":1.0,"LONGVIDEOBENCH_VIDEO_MAX_PIXELS":262144,"LONGVIDEOBENCH_VIDEO_MIN_PIXELS":4096,"LONGVIDEOBENCH_VIDEO_TOTAL_PIXELS":0},"license":"See the upstream benchmark terms","media_paths":["media/video_qa/longvideobench/subtitles","media/video_qa/longvideobench/subtitles/val","media/video_qa/longvideobench/videos","media/video_qa/longvideobench/videos/val"],"preprocessing":{"batch_size":1,"enable_thinking":false,"fps":2,"gpu_memory_utilization":0.9,"max_frames":384,"max_model_len":65536,"max_new_tokens":128,"max_num_batched_tokens":65536,"min_p":0.0,"presence_penalty":0.0,"prompt_mode":"default","temperature":0.0,"top_k":-1,"top_p":1.0,"video_max_pixels":262144,"video_min_pixels":4096,"video_total_pixels":0},"redistribution_authorized":true,"schema_version":1,"source_url":"https://github.com/longvideobench/LongVideoBench","split":"val","task":"longvideobench"}
+{"annotation_path":"annotations/spatial_intelligence/mindcube/test.jsonl","artifact_paths":[],"benchmark":"mindcube","evaluation":{"aggregation":"micro","expected_group_counts":{"among":600,"around":250,"rotation":200},"metric_profile":"micro_accuracy","parser_profile":"multiple_choice","prompt_profile":"mindcube_official"},"expected_count":1050,"family":"spatial_intelligence","legacy_environment":{"MINDCUBE_BATCH_SIZE":16,"MINDCUBE_MAX_MODEL_LEN":32768,"MINDCUBE_MAX_NEW_TOKENS":1024},"license":"See the upstream benchmark terms","media_paths":["media/spatial_intelligence/mindcube/images","media/spatial_intelligence/mindcube/images/test"],"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/oscarqjh/MindCube_lmmseval/tree/7dd2725d9bd4149f2aad00a9843f72a3824da003","split":"test","task":"mindcube"}
+{"annotation_path":"annotations/video_qa/mlvu/dev.jsonl","artifact_paths":[],"benchmark":"mlvu","evaluation":{"metric_profile":"macro_accuracy","parser_profile":"multiple_choice","prompt_profile":"mlvu_default"},"expected_count":2174,"family":"video_qa","legacy_environment":{"MLVU_BATCH_SIZE":1,"MLVU_ENABLE_THINKING":false,"MLVU_FPS":2,"MLVU_GPU_MEMORY_UTILIZATION":0.9,"MLVU_MAX_FRAMES":384,"MLVU_MAX_MODEL_LEN":65536,"MLVU_MAX_NEW_TOKENS":128,"MLVU_MAX_NUM_BATCHED_TOKENS":65536,"MLVU_MIN_P":0.0,"MLVU_PRESENCE_PENALTY":0.0,"MLVU_PROMPT_MODE":"default","MLVU_TEMPERATURE":0.0,"MLVU_TOP_K":-1,"MLVU_TOP_P":1.0,"MLVU_VIDEO_MAX_PIXELS":262144,"MLVU_VIDEO_MIN_PIXELS":4096,"MLVU_VIDEO_TOTAL_PIXELS":0},"license":"See the upstream benchmark terms","media_paths":["media/video_qa/mlvu/videos","media/video_qa/mlvu/videos/dev"],"preprocessing":{"batch_size":1,"enable_thinking":false,"fps":2,"gpu_memory_utilization":0.9,"max_frames":384,"max_model_len":65536,"max_new_tokens":128,"max_num_batched_tokens":65536,"min_p":0.0,"presence_penalty":0.0,"prompt_mode":"default","temperature":0.0,"top_k":-1,"top_p":1.0,"video_max_pixels":262144,"video_min_pixels":4096,"video_total_pixels":0},"redistribution_authorized":true,"schema_version":1,"source_url":"https://github.com/JUNJIE99/MLVU","split":"dev","task":"mlvu"}
+{"annotation_path":"annotations/spatial_intelligence/mmsi/test.jsonl","artifact_paths":[],"benchmark":"mmsi","evaluation":{"metric_profile":"accuracy","parser_profile":"multiple_choice","prompt_profile":"mmsi_default"},"expected_count":1000,"family":"spatial_intelligence","legacy_environment":{"MMSI_BACKEND":"transformers","MMSI_ENABLE_THINKING":false,"MMSI_IMAGE_MAX_PIXELS":262144,"MMSI_IMAGE_MIN_PIXELS":4096},"license":"See the upstream benchmark terms","media_paths":["media/spatial_intelligence/mmsi/images/test"],"preprocessing":{"image_max_pixels":262144,"image_min_pixels":4096},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/RunsenXu/MMSI-Bench","split":"test","task":"mmsi"}
+{"annotation_path":"annotations/video_qa/mmvu/test.jsonl","artifact_paths":[],"benchmark":"mmvu","evaluation":{"metric_profile":"accuracy","parser_profile":"multiple_choice","prompt_profile":"mmvu_default"},"expected_count":625,"family":"video_qa","legacy_environment":{"MMVU_BATCH_SIZE":1,"MMVU_ENABLE_THINKING":false,"MMVU_FPS":2,"MMVU_GPU_MEMORY_UTILIZATION":0.9,"MMVU_MAX_FRAMES":384,"MMVU_MAX_MODEL_LEN":65536,"MMVU_MAX_NEW_TOKENS":128,"MMVU_MAX_NUM_BATCHED_TOKENS":65536,"MMVU_MIN_P":0.0,"MMVU_PRESENCE_PENALTY":0.0,"MMVU_PROMPT_MODE":"default","MMVU_TEMPERATURE":0.0,"MMVU_TOP_K":-1,"MMVU_TOP_P":1.0,"MMVU_VIDEO_MAX_PIXELS":262144,"MMVU_VIDEO_MIN_PIXELS":4096,"MMVU_VIDEO_TOTAL_PIXELS":0},"license":"See the upstream benchmark terms","media_paths":["media/video_qa/mmvu/videos","media/video_qa/mmvu/videos/test"],"preprocessing":{"batch_size":1,"enable_thinking":false,"fps":2,"gpu_memory_utilization":0.9,"max_frames":384,"max_model_len":65536,"max_new_tokens":128,"max_num_batched_tokens":65536,"min_p":0.0,"presence_penalty":0.0,"prompt_mode":"default","temperature":0.0,"top_k":-1,"top_p":1.0,"video_max_pixels":262144,"video_min_pixels":4096,"video_total_pixels":0},"redistribution_authorized":true,"schema_version":1,"source_url":"https://github.com/yale-nlp/MMVU","split":"test","task":"mmvu"}
+{"annotation_path":"annotations/video_qa/mvbench/test.jsonl","artifact_paths":[],"benchmark":"mvbench","evaluation":{"metric_profile":"accuracy","parser_profile":"multiple_choice","prompt_profile":"mvbench_default"},"expected_count":3586,"family":"video_qa","legacy_environment":{"MVBENCH_BATCH_SIZE":1,"MVBENCH_ENABLE_THINKING":false,"MVBENCH_FPS":2,"MVBENCH_GPU_MEMORY_UTILIZATION":0.9,"MVBENCH_MAX_FRAMES":384,"MVBENCH_MAX_MODEL_LEN":65536,"MVBENCH_MAX_NEW_TOKENS":128,"MVBENCH_MAX_NUM_BATCHED_TOKENS":65536,"MVBENCH_MIN_P":0.0,"MVBENCH_PRESENCE_PENALTY":0.0,"MVBENCH_PROMPT_MODE":"default","MVBENCH_TEMPERATURE":0.0,"MVBENCH_TOP_K":-1,"MVBENCH_TOP_P":1.0,"MVBENCH_VIDEO_MAX_PIXELS":262144,"MVBENCH_VIDEO_MIN_PIXELS":4096,"MVBENCH_VIDEO_TOTAL_PIXELS":0},"license":"See the upstream benchmark terms","media_paths":["media/video_qa/mvbench/videos","media/video_qa/mvbench/videos/test"],"preprocessing":{"batch_size":1,"enable_thinking":false,"fps":2,"gpu_memory_utilization":0.9,"max_frames":384,"max_model_len":65536,"max_new_tokens":128,"max_num_batched_tokens":65536,"min_p":0.0,"presence_penalty":0.0,"prompt_mode":"default","temperature":0.0,"top_k":-1,"top_p":1.0,"video_max_pixels":262144,"video_min_pixels":4096,"video_total_pixels":0},"redistribution_authorized":true,"schema_version":1,"source_url":"https://github.com/OpenGVLab/Ask-Anything","split":"test","task":"mvbench"}
+{"annotation_path":"annotations/spatial_intelligence/revsi/test.jsonl","artifact_paths":[],"benchmark":"revsi","evaluation":{"aggregation":"macro_by_question_type","frame_protocol":"native_all_frame","metric_profile":"revsi_macro_average","parser_profile":"revsi_answer","prompt_profile":"revsi_official"},"expected_count":6808,"family":"spatial_intelligence","legacy_environment":{"REVSI_BATCH_SIZE":16,"REVSI_ENABLE_THINKING":false,"REVSI_EXACT_NFRAMES":true,"REVSI_EXPECTED_SAMPLES":6808,"REVSI_FPS":2,"REVSI_FRAME_BUDGET":"all","REVSI_GPU_MEMORY_UTILIZATION":0.9,"REVSI_MAX_FRAMES":128,"REVSI_MAX_MODEL_LEN":32768,"REVSI_MAX_NEW_TOKENS":64,"REVSI_SETTING":"native-all-f128-exacttrue-fps2-min65536-maxnone-total16777216","REVSI_VIDEO_MIN_PIXELS":65536,"REVSI_VIDEO_TOTAL_PIXELS":16777216},"license":"See the upstream benchmark terms","media_paths":["media/spatial_intelligence/revsi/videos","media/spatial_intelligence/revsi/videos/test"],"preprocessing":{"exact_nframes":true,"fps":2,"max_frames":128,"video_min_pixels":65536,"video_total_pixels":16777216},"redistribution_authorized":true,"schema_version":1,"source_url":"https://arxiv.org/abs/2605.25979","split":"test","task":"revsi"}
+{"annotation_path":"annotations/segmentation/mevis.jsonl","artifact_paths":[],"benchmark":"segmentation","evaluation":{"metric_profile":"segmentation_official","oracle_location":"task_payload.segmentation_output","oracle_profile":"segmentation_rle","parser_profile":"sam2_prompt","prompt_profile":"train_seg"},"expected_count":424,"family":"segmentation","legacy_environment":{"SEGMENTATION_BATCH_SIZE":16,"SEGMENTATION_DATASETS":"refcoco,refcocop,refcocog,mevis,reasonvos","SEGMENTATION_DATA_TYPE":"all","SEGMENTATION_ENABLE_THINKING":false,"SEGMENTATION_FPS":2,"SEGMENTATION_GPU_MEM_UTIL":0.85,"SEGMENTATION_MAX_FRAMES":128,"SEGMENTATION_MAX_MODEL_LEN":32768,"SEGMENTATION_MAX_NEW_TOKENS":1024,"SEGMENTATION_PROMPT_MODE":"train_seg","SEGMENTATION_RUN_SAM2":false,"SEGMENTATION_SEED":42,"SEGMENTATION_SETTING":"segmentation-refcoco_refcocop_refcocog_mevis_reasonvos-train_seg-f128-fps2-min4096-max262144-total16777216-readerdecord-new1024","SEGMENTATION_VIDEO_MAX_PIXELS":262144,"SEGMENTATION_VIDEO_MIN_PIXELS":4096,"SEGMENTATION_VIDEO_READER":"decord","SEGMENTATION_VIDEO_TOTAL_PIXELS":16777216},"license":"See the upstream benchmark terms","media_paths":["media/segmentation","media/segmentation/videos/mevis"],"preprocessing":{"fps":2,"max_frames":128,"video_max_pixels":262144,"video_min_pixels":4096,"video_reader":"decord","video_total_pixels":16777216},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"mevis","task":"segmentation"}
+{"annotation_path":"annotations/segmentation/reasonvos.jsonl","artifact_paths":[],"benchmark":"segmentation","evaluation":{"metric_profile":"segmentation_official","oracle_location":"task_payload.segmentation_output","oracle_profile":"segmentation_rle","parser_profile":"sam2_prompt","prompt_profile":"train_seg"},"expected_count":458,"family":"segmentation","legacy_environment":{"SEGMENTATION_BATCH_SIZE":16,"SEGMENTATION_DATASETS":"refcoco,refcocop,refcocog,mevis,reasonvos","SEGMENTATION_DATA_TYPE":"all","SEGMENTATION_ENABLE_THINKING":false,"SEGMENTATION_FPS":2,"SEGMENTATION_GPU_MEM_UTIL":0.85,"SEGMENTATION_MAX_FRAMES":128,"SEGMENTATION_MAX_MODEL_LEN":32768,"SEGMENTATION_MAX_NEW_TOKENS":1024,"SEGMENTATION_PROMPT_MODE":"train_seg","SEGMENTATION_RUN_SAM2":false,"SEGMENTATION_SEED":42,"SEGMENTATION_SETTING":"segmentation-refcoco_refcocop_refcocog_mevis_reasonvos-train_seg-f128-fps2-min4096-max262144-total16777216-readerdecord-new1024","SEGMENTATION_VIDEO_MAX_PIXELS":262144,"SEGMENTATION_VIDEO_MIN_PIXELS":4096,"SEGMENTATION_VIDEO_READER":"decord","SEGMENTATION_VIDEO_TOTAL_PIXELS":16777216},"license":"See the upstream benchmark terms","media_paths":["media/segmentation","media/segmentation/videos/reasonvos"],"preprocessing":{"fps":2,"max_frames":128,"video_max_pixels":262144,"video_min_pixels":4096,"video_reader":"decord","video_total_pixels":16777216},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"reasonvos","task":"segmentation"}
+{"annotation_path":"annotations/segmentation/refcoco.jsonl","artifact_paths":[],"benchmark":"segmentation","evaluation":{"metric_profile":"segmentation_official","oracle_location":"task_payload.segmentation_output","oracle_profile":"segmentation_rle","parser_profile":"sam2_prompt","prompt_profile":"train_seg"},"expected_count":3811,"family":"segmentation","legacy_environment":{"SEGMENTATION_BATCH_SIZE":16,"SEGMENTATION_DATASETS":"refcoco,refcocop,refcocog,mevis,reasonvos","SEGMENTATION_DATA_TYPE":"all","SEGMENTATION_ENABLE_THINKING":false,"SEGMENTATION_FPS":2,"SEGMENTATION_GPU_MEM_UTIL":0.85,"SEGMENTATION_MAX_FRAMES":128,"SEGMENTATION_MAX_MODEL_LEN":32768,"SEGMENTATION_MAX_NEW_TOKENS":1024,"SEGMENTATION_PROMPT_MODE":"train_seg","SEGMENTATION_RUN_SAM2":false,"SEGMENTATION_SEED":42,"SEGMENTATION_SETTING":"segmentation-refcoco_refcocop_refcocog_mevis_reasonvos-train_seg-f128-fps2-min4096-max262144-total16777216-readerdecord-new1024","SEGMENTATION_VIDEO_MAX_PIXELS":262144,"SEGMENTATION_VIDEO_MIN_PIXELS":4096,"SEGMENTATION_VIDEO_READER":"decord","SEGMENTATION_VIDEO_TOTAL_PIXELS":16777216},"license":"See the upstream benchmark terms","media_paths":["media/segmentation","media/segmentation/images/refcoco"],"preprocessing":{"fps":2,"max_frames":128,"video_max_pixels":262144,"video_min_pixels":4096,"video_reader":"decord","video_total_pixels":16777216},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcoco","task":"segmentation"}
+{"annotation_path":"annotations/segmentation/refcocog.jsonl","artifact_paths":[],"benchmark":"segmentation","evaluation":{"metric_profile":"segmentation_official","oracle_location":"task_payload.segmentation_output","oracle_profile":"segmentation_rle","parser_profile":"sam2_prompt","prompt_profile":"train_seg"},"expected_count":2537,"family":"segmentation","legacy_environment":{"SEGMENTATION_BATCH_SIZE":16,"SEGMENTATION_DATASETS":"refcoco,refcocop,refcocog,mevis,reasonvos","SEGMENTATION_DATA_TYPE":"all","SEGMENTATION_ENABLE_THINKING":false,"SEGMENTATION_FPS":2,"SEGMENTATION_GPU_MEM_UTIL":0.85,"SEGMENTATION_MAX_FRAMES":128,"SEGMENTATION_MAX_MODEL_LEN":32768,"SEGMENTATION_MAX_NEW_TOKENS":1024,"SEGMENTATION_PROMPT_MODE":"train_seg","SEGMENTATION_RUN_SAM2":false,"SEGMENTATION_SEED":42,"SEGMENTATION_SETTING":"segmentation-refcoco_refcocop_refcocog_mevis_reasonvos-train_seg-f128-fps2-min4096-max262144-total16777216-readerdecord-new1024","SEGMENTATION_VIDEO_MAX_PIXELS":262144,"SEGMENTATION_VIDEO_MIN_PIXELS":4096,"SEGMENTATION_VIDEO_READER":"decord","SEGMENTATION_VIDEO_TOTAL_PIXELS":16777216},"license":"See the upstream benchmark terms","media_paths":["media/segmentation","media/segmentation/images/refcocog"],"preprocessing":{"fps":2,"max_frames":128,"video_max_pixels":262144,"video_min_pixels":4096,"video_reader":"decord","video_total_pixels":16777216},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcocog","task":"segmentation"}
+{"annotation_path":"annotations/segmentation/refcocop.jsonl","artifact_paths":[],"benchmark":"segmentation","evaluation":{"metric_profile":"segmentation_official","oracle_location":"task_payload.segmentation_output","oracle_profile":"segmentation_rle","parser_profile":"sam2_prompt","prompt_profile":"train_seg"},"expected_count":3805,"family":"segmentation","legacy_environment":{"SEGMENTATION_BATCH_SIZE":16,"SEGMENTATION_DATASETS":"refcoco,refcocop,refcocog,mevis,reasonvos","SEGMENTATION_DATA_TYPE":"all","SEGMENTATION_ENABLE_THINKING":false,"SEGMENTATION_FPS":2,"SEGMENTATION_GPU_MEM_UTIL":0.85,"SEGMENTATION_MAX_FRAMES":128,"SEGMENTATION_MAX_MODEL_LEN":32768,"SEGMENTATION_MAX_NEW_TOKENS":1024,"SEGMENTATION_PROMPT_MODE":"train_seg","SEGMENTATION_RUN_SAM2":false,"SEGMENTATION_SEED":42,"SEGMENTATION_SETTING":"segmentation-refcoco_refcocop_refcocog_mevis_reasonvos-train_seg-f128-fps2-min4096-max262144-total16777216-readerdecord-new1024","SEGMENTATION_VIDEO_MAX_PIXELS":262144,"SEGMENTATION_VIDEO_MIN_PIXELS":4096,"SEGMENTATION_VIDEO_READER":"decord","SEGMENTATION_VIDEO_TOTAL_PIXELS":16777216},"license":"See the upstream benchmark terms","media_paths":["media/segmentation","media/segmentation/images/refcocop"],"preprocessing":{"fps":2,"max_frames":128,"video_max_pixels":262144,"video_min_pixels":4096,"video_reader":"decord","video_total_pixels":16777216},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcocop","task":"segmentation"}
+{"annotation_path":"annotations/spatial_grounding/refcoco_test_a.jsonl","artifact_paths":[],"benchmark":"spatial_grounding","evaluation":{"metric_profile":"refcoco_iou","parser_profile":"norm1000_bbox","prompt_profile":"qwen_native"},"expected_count":5657,"family":"spatial_grounding","legacy_environment":{"SPATIAL_GROUNDING_BBOX_SELECT":"first","SPATIAL_GROUNDING_COORD_SYSTEM":"norm1000","SPATIAL_GROUNDING_DATASETS":"refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test","SPATIAL_GROUNDING_MAX_NEW_TOKENS":1024,"SPATIAL_GROUNDING_MIN_TOKENS":64,"SPATIAL_GROUNDING_PROMPT_STYLE":"qwen_native","SPATIAL_GROUNDING_TOTAL_TOKENS":1024},"license":"See the upstream benchmark terms","media_paths":["media/spatial_grounding/images","media/spatial_grounding/images/refcoco_test_a"],"preprocessing":{"coordinate_system":"norm1000"},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcoco_test_a","task":"spatial_grounding"}
+{"annotation_path":"annotations/spatial_grounding/refcoco_test_b.jsonl","artifact_paths":[],"benchmark":"spatial_grounding","evaluation":{"metric_profile":"refcoco_iou","parser_profile":"norm1000_bbox","prompt_profile":"qwen_native"},"expected_count":5095,"family":"spatial_grounding","legacy_environment":{"SPATIAL_GROUNDING_BBOX_SELECT":"first","SPATIAL_GROUNDING_COORD_SYSTEM":"norm1000","SPATIAL_GROUNDING_DATASETS":"refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test","SPATIAL_GROUNDING_MAX_NEW_TOKENS":1024,"SPATIAL_GROUNDING_MIN_TOKENS":64,"SPATIAL_GROUNDING_PROMPT_STYLE":"qwen_native","SPATIAL_GROUNDING_TOTAL_TOKENS":1024},"license":"See the upstream benchmark terms","media_paths":["media/spatial_grounding/images","media/spatial_grounding/images/refcoco_test_b"],"preprocessing":{"coordinate_system":"norm1000"},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcoco_test_b","task":"spatial_grounding"}
+{"annotation_path":"annotations/spatial_grounding/refcoco_val.jsonl","artifact_paths":[],"benchmark":"spatial_grounding","evaluation":{"metric_profile":"refcoco_iou","parser_profile":"norm1000_bbox","prompt_profile":"qwen_native"},"expected_count":10834,"family":"spatial_grounding","legacy_environment":{"SPATIAL_GROUNDING_BBOX_SELECT":"first","SPATIAL_GROUNDING_COORD_SYSTEM":"norm1000","SPATIAL_GROUNDING_DATASETS":"refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test","SPATIAL_GROUNDING_MAX_NEW_TOKENS":1024,"SPATIAL_GROUNDING_MIN_TOKENS":64,"SPATIAL_GROUNDING_PROMPT_STYLE":"qwen_native","SPATIAL_GROUNDING_TOTAL_TOKENS":1024},"license":"See the upstream benchmark terms","media_paths":["media/spatial_grounding/images","media/spatial_grounding/images/refcoco_val"],"preprocessing":{"coordinate_system":"norm1000"},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcoco_val","task":"spatial_grounding"}
+{"annotation_path":"annotations/spatial_grounding/refcocog_test.jsonl","artifact_paths":[],"benchmark":"spatial_grounding","evaluation":{"metric_profile":"refcoco_iou","parser_profile":"norm1000_bbox","prompt_profile":"qwen_native"},"expected_count":9602,"family":"spatial_grounding","legacy_environment":{"SPATIAL_GROUNDING_BBOX_SELECT":"first","SPATIAL_GROUNDING_COORD_SYSTEM":"norm1000","SPATIAL_GROUNDING_DATASETS":"refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test","SPATIAL_GROUNDING_MAX_NEW_TOKENS":1024,"SPATIAL_GROUNDING_MIN_TOKENS":64,"SPATIAL_GROUNDING_PROMPT_STYLE":"qwen_native","SPATIAL_GROUNDING_TOTAL_TOKENS":1024},"license":"See the upstream benchmark terms","media_paths":["media/spatial_grounding/images","media/spatial_grounding/images/refcocog_test"],"preprocessing":{"coordinate_system":"norm1000"},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcocog_test","task":"spatial_grounding"}
+{"annotation_path":"annotations/spatial_grounding/refcocog_val.jsonl","artifact_paths":[],"benchmark":"spatial_grounding","evaluation":{"metric_profile":"refcoco_iou","parser_profile":"norm1000_bbox","prompt_profile":"qwen_native"},"expected_count":4896,"family":"spatial_grounding","legacy_environment":{"SPATIAL_GROUNDING_BBOX_SELECT":"first","SPATIAL_GROUNDING_COORD_SYSTEM":"norm1000","SPATIAL_GROUNDING_DATASETS":"refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test","SPATIAL_GROUNDING_MAX_NEW_TOKENS":1024,"SPATIAL_GROUNDING_MIN_TOKENS":64,"SPATIAL_GROUNDING_PROMPT_STYLE":"qwen_native","SPATIAL_GROUNDING_TOTAL_TOKENS":1024},"license":"See the upstream benchmark terms","media_paths":["media/spatial_grounding/images","media/spatial_grounding/images/refcocog_val"],"preprocessing":{"coordinate_system":"norm1000"},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcocog_val","task":"spatial_grounding"}
+{"annotation_path":"annotations/spatial_grounding/refcocop_test_a.jsonl","artifact_paths":[],"benchmark":"spatial_grounding","evaluation":{"metric_profile":"refcoco_iou","parser_profile":"norm1000_bbox","prompt_profile":"qwen_native"},"expected_count":5726,"family":"spatial_grounding","legacy_environment":{"SPATIAL_GROUNDING_BBOX_SELECT":"first","SPATIAL_GROUNDING_COORD_SYSTEM":"norm1000","SPATIAL_GROUNDING_DATASETS":"refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test","SPATIAL_GROUNDING_MAX_NEW_TOKENS":1024,"SPATIAL_GROUNDING_MIN_TOKENS":64,"SPATIAL_GROUNDING_PROMPT_STYLE":"qwen_native","SPATIAL_GROUNDING_TOTAL_TOKENS":1024},"license":"See the upstream benchmark terms","media_paths":["media/spatial_grounding/images","media/spatial_grounding/images/refcocop_test_a"],"preprocessing":{"coordinate_system":"norm1000"},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcocop_test_a","task":"spatial_grounding"}
+{"annotation_path":"annotations/spatial_grounding/refcocop_test_b.jsonl","artifact_paths":[],"benchmark":"spatial_grounding","evaluation":{"metric_profile":"refcoco_iou","parser_profile":"norm1000_bbox","prompt_profile":"qwen_native"},"expected_count":4889,"family":"spatial_grounding","legacy_environment":{"SPATIAL_GROUNDING_BBOX_SELECT":"first","SPATIAL_GROUNDING_COORD_SYSTEM":"norm1000","SPATIAL_GROUNDING_DATASETS":"refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test","SPATIAL_GROUNDING_MAX_NEW_TOKENS":1024,"SPATIAL_GROUNDING_MIN_TOKENS":64,"SPATIAL_GROUNDING_PROMPT_STYLE":"qwen_native","SPATIAL_GROUNDING_TOTAL_TOKENS":1024},"license":"See the upstream benchmark terms","media_paths":["media/spatial_grounding/images","media/spatial_grounding/images/refcocop_test_b"],"preprocessing":{"coordinate_system":"norm1000"},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcocop_test_b","task":"spatial_grounding"}
+{"annotation_path":"annotations/spatial_grounding/refcocop_val.jsonl","artifact_paths":[],"benchmark":"spatial_grounding","evaluation":{"metric_profile":"refcoco_iou","parser_profile":"norm1000_bbox","prompt_profile":"qwen_native"},"expected_count":10758,"family":"spatial_grounding","legacy_environment":{"SPATIAL_GROUNDING_BBOX_SELECT":"first","SPATIAL_GROUNDING_COORD_SYSTEM":"norm1000","SPATIAL_GROUNDING_DATASETS":"refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test","SPATIAL_GROUNDING_MAX_NEW_TOKENS":1024,"SPATIAL_GROUNDING_MIN_TOKENS":64,"SPATIAL_GROUNDING_PROMPT_STYLE":"qwen_native","SPATIAL_GROUNDING_TOTAL_TOKENS":1024},"license":"See the upstream benchmark terms","media_paths":["media/spatial_grounding/images","media/spatial_grounding/images/refcocop_val"],"preprocessing":{"coordinate_system":"norm1000"},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"refcocop_val","task":"spatial_grounding"}
+{"annotation_path":"annotations/stvg/stvg.jsonl","artifact_paths":[],"benchmark":"stvg","evaluation":{"metric_profile":"stvg_official","parser_profile":"temporal_spatial_boxes","prompt_profile":"train_stvg"},"expected_count":1851,"family":"spatial_temporal_grounding","legacy_environment":{"STVG_DATASETS":"eval_stvg","STVG_FPS":2,"STVG_MAX_FRAMES":128,"STVG_MAX_NEW_TOKENS":2048,"STVG_PROMPT_MODE":"train_stvg","STVG_VIDEO_MAX_PIXELS":393216,"STVG_VIDEO_MIN_PIXELS":65536,"STVG_VIDEO_TOTAL_PIXELS":10485760},"license":"See the upstream benchmark terms","media_paths":["media/stvg","media/stvg/videos/stvg"],"preprocessing":{"stvg_fps":2,"stvg_max_frames":128,"stvg_video_max_pixels":393216,"stvg_video_min_pixels":65536,"stvg_video_total_pixels":10485760},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"stvg","task":"stvg"}
+{"annotation_path":"annotations/temporal_grounding/activitynet_timelens.jsonl","artifact_paths":[],"benchmark":"temporal_grounding","checksums":{"annotations/temporal_grounding/activitynet_timelens.jsonl":"3091f03a20b2bf4548d4079d95bb8dc564da53d4e6b2c32470862b15622c7a77"},"evaluation":{"metric_profile":"temporal_iou","parser_profile":"temporal_spans","prompt_profile":"timelens_same"},"expected_count":4500,"family":"temporal_grounding","legacy_environment":{"TIMELENS_DATASETS":"charades_timelens,activitynet_timelens,qvhighlights_timelens","TIMELENS_ENABLE_THINKING":false,"TIMELENS_FPS":4,"TIMELENS_MAX_FRAMES":2048,"TIMELENS_MAX_NEW_TOKENS":128,"TIMELENS_MAX_PIXELS":409600,"TIMELENS_MIN_TOKENS":1,"TIMELENS_NUM_WORKERS":2,"TIMELENS_PROMPT_MODE":"same","TIMELENS_STOP_AFTER_ANSWER":true,"TIMELENS_TOTAL_TOKENS":128000},"license":"See the upstream benchmark terms","media_paths":["media/temporal_grounding/videos"],"preprocessing":{"fps":4,"max_frames":2048,"max_pixels":409600,"min_tokens":1,"total_tokens":128000},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/TencentARC/TimeLens-Bench","split":"activitynet_timelens","task":"temporal_grounding"}
+{"annotation_path":"annotations/temporal_grounding/charades_timelens.jsonl","artifact_paths":[],"benchmark":"temporal_grounding","checksums":{"annotations/temporal_grounding/charades_timelens.jsonl":"af289d9be3e8c56f98b12c39bda37758a34205c1aa75e2c03e3174dbbe6f2794"},"evaluation":{"metric_profile":"temporal_iou","parser_profile":"temporal_spans","prompt_profile":"timelens_same"},"expected_count":3363,"family":"temporal_grounding","legacy_environment":{"TIMELENS_DATASETS":"charades_timelens,activitynet_timelens,qvhighlights_timelens","TIMELENS_ENABLE_THINKING":false,"TIMELENS_FPS":4,"TIMELENS_MAX_FRAMES":2048,"TIMELENS_MAX_NEW_TOKENS":128,"TIMELENS_MAX_PIXELS":409600,"TIMELENS_MIN_TOKENS":1,"TIMELENS_NUM_WORKERS":2,"TIMELENS_PROMPT_MODE":"same","TIMELENS_STOP_AFTER_ANSWER":true,"TIMELENS_TOTAL_TOKENS":128000},"license":"See the upstream benchmark terms","media_paths":["media/temporal_grounding/videos"],"preprocessing":{"fps":4,"max_frames":2048,"max_pixels":409600,"min_tokens":1,"total_tokens":128000},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/TencentARC/TimeLens-Bench","split":"charades_timelens","task":"temporal_grounding"}
+{"annotation_path":"annotations/temporal_grounding/qvhighlights_timelens.jsonl","artifact_paths":[],"benchmark":"temporal_grounding","checksums":{"annotations/temporal_grounding/qvhighlights_timelens.jsonl":"922a736a73ff9fb3d3996e0b75ee1343283bee23d7e60b368d6840f8612e619b"},"evaluation":{"metric_profile":"temporal_iou","parser_profile":"temporal_spans","prompt_profile":"timelens_same"},"expected_count":1541,"family":"temporal_grounding","legacy_environment":{"TIMELENS_DATASETS":"charades_timelens,activitynet_timelens,qvhighlights_timelens","TIMELENS_ENABLE_THINKING":false,"TIMELENS_FPS":4,"TIMELENS_MAX_FRAMES":2048,"TIMELENS_MAX_NEW_TOKENS":128,"TIMELENS_MAX_PIXELS":409600,"TIMELENS_MIN_TOKENS":1,"TIMELENS_NUM_WORKERS":2,"TIMELENS_PROMPT_MODE":"same","TIMELENS_STOP_AFTER_ANSWER":true,"TIMELENS_TOTAL_TOKENS":128000},"license":"See the upstream benchmark terms","media_paths":["media/temporal_grounding/videos"],"preprocessing":{"fps":4,"max_frames":2048,"max_pixels":409600,"min_tokens":1,"total_tokens":128000},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/TencentARC/TimeLens-Bench","split":"qvhighlights_timelens","task":"temporal_grounding"}
+{"annotation_path":"annotations/tracking/got10k.jsonl","artifact_paths":[],"benchmark":"tracking","checksums":{"annotations/tracking/got10k.jsonl":"1206da3c1fd75360ccea074a9e211965fe2d8cfd514903564b037641a00fb12b"},"evaluation":{"metric_profile":"got10k_ao","parser_profile":"tracking_boxes","prompt_profile":"tracking_default"},"expected_count":180,"family":"tracking","legacy_environment":{"TRACKING_DATASETS":"eval_got10k","TRACKING_FPS":1,"TRACKING_MAX_FRAMES":32,"TRACKING_MAX_NEW_TOKENS":8192,"TRACKING_PROMPT_MODE":"default","TRACKING_VIDEO_MAX_PIXELS":786432,"TRACKING_VIDEO_MIN_PIXELS":4096,"TRACKING_VIDEO_TOTAL_PIXELS":8388608},"license":"See the upstream benchmark terms","media_paths":["media/tracking","media/tracking/videos"],"preprocessing":{"tracking_fps":1,"tracking_max_frames":32,"tracking_video_max_pixels":786432,"tracking_video_min_pixels":4096,"tracking_video_total_pixels":8388608},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/OneThink/OneThinker-eval","split":"got10k","task":"tracking"}
+{"annotation_path":"annotations/video_qa/videoholmes/test.jsonl","artifact_paths":[],"benchmark":"videoholmes","evaluation":{"metric_profile":"accuracy","parser_profile":"multiple_choice","prompt_profile":"videoholmes_default"},"expected_count":1837,"family":"video_qa","legacy_environment":{"VIDEOHOLMES_BATCH_SIZE":1,"VIDEOHOLMES_ENABLE_THINKING":false,"VIDEOHOLMES_FPS":2,"VIDEOHOLMES_GPU_MEMORY_UTILIZATION":0.9,"VIDEOHOLMES_MAX_FRAMES":384,"VIDEOHOLMES_MAX_MODEL_LEN":65536,"VIDEOHOLMES_MAX_NEW_TOKENS":128,"VIDEOHOLMES_MAX_NUM_BATCHED_TOKENS":65536,"VIDEOHOLMES_MIN_P":0.0,"VIDEOHOLMES_PRESENCE_PENALTY":0.0,"VIDEOHOLMES_PROMPT_MODE":"default","VIDEOHOLMES_TEMPERATURE":0.0,"VIDEOHOLMES_TOP_K":-1,"VIDEOHOLMES_TOP_P":1.0,"VIDEOHOLMES_VIDEO_MAX_PIXELS":262144,"VIDEOHOLMES_VIDEO_MIN_PIXELS":4096,"VIDEOHOLMES_VIDEO_TOTAL_PIXELS":0},"license":"See the upstream benchmark terms","media_paths":["media/video_qa/videoholmes/videos","media/video_qa/videoholmes/videos/test"],"preprocessing":{"batch_size":1,"enable_thinking":false,"fps":2,"gpu_memory_utilization":0.9,"max_frames":384,"max_model_len":65536,"max_new_tokens":128,"max_num_batched_tokens":65536,"min_p":0.0,"presence_penalty":0.0,"prompt_mode":"default","temperature":0.0,"top_k":-1,"top_p":1.0,"video_max_pixels":262144,"video_min_pixels":4096,"video_total_pixels":0},"redistribution_authorized":true,"schema_version":1,"source_url":"https://github.com/TencentARC/Video-Holmes","split":"test","task":"videoholmes"}
+{"annotation_path":"annotations/video_qa/videomme/test.jsonl","artifact_paths":["artifacts/video_qa/videomme"],"benchmark":"videomme","evaluation":{"metric_profile":"accuracy","parser_profile":"multiple_choice","prompt_profile":"videomme_default"},"expected_count":2700,"family":"video_qa","legacy_environment":{"VIDEOMME_BATCH_SIZE":1,"VIDEOMME_ENABLE_THINKING":false,"VIDEOMME_FPS":2,"VIDEOMME_GPU_MEMORY_UTILIZATION":0.9,"VIDEOMME_MAX_FRAMES":384,"VIDEOMME_MAX_MODEL_LEN":65536,"VIDEOMME_MAX_NEW_TOKENS":128,"VIDEOMME_MAX_NUM_BATCHED_TOKENS":65536,"VIDEOMME_MIN_P":0.0,"VIDEOMME_PRESENCE_PENALTY":0.0,"VIDEOMME_PROMPT_MODE":"default","VIDEOMME_SETTING":"all-qwen3_vl-sub0-f384-fps2-min4096-max262144-total0-videomme_preprocessed_384f_262k_total0","VIDEOMME_TEMPERATURE":0.0,"VIDEOMME_TOP_K":-1,"VIDEOMME_TOP_P":1.0,"VIDEOMME_VIDEO_MAX_PIXELS":262144,"VIDEOMME_VIDEO_MIN_PIXELS":4096,"VIDEOMME_VIDEO_TOTAL_PIXELS":0},"license":"See the upstream benchmark terms","media_paths":["media/video_qa/videomme/videos","media/video_qa/videomme/videos/test"],"preprocessing":{"batch_size":1,"enable_thinking":false,"fps":2,"gpu_memory_utilization":0.9,"max_frames":384,"max_model_len":65536,"max_new_tokens":128,"max_num_batched_tokens":65536,"min_p":0.0,"presence_penalty":0.0,"prompt_mode":"default","temperature":0.0,"top_k":-1,"top_p":1.0,"video_max_pixels":262144,"video_min_pixels":4096,"video_total_pixels":0},"redistribution_authorized":true,"schema_version":1,"source_url":"https://github.com/BradyFU/Video-MME","split":"test","task":"videomme"}
+{"annotation_path":"annotations/video_qa/videommev2/test.jsonl","artifact_paths":["artifacts/video_qa/videommev2"],"benchmark":"videommev2","evaluation":{"metric_profile":"accuracy","parser_profile":"multiple_choice","prompt_profile":"videommev2_default"},"expected_count":3200,"family":"video_qa","legacy_environment":{"VIDEOMMEV2_ANSWER_FILTER":"","VIDEOMMEV2_BATCH_SIZE":1,"VIDEOMMEV2_ENABLE_THINKING":false,"VIDEOMMEV2_FPS":2,"VIDEOMMEV2_GPU_MEMORY_UTILIZATION":0.9,"VIDEOMMEV2_MAX_FRAMES":384,"VIDEOMMEV2_MAX_MODEL_LEN":65536,"VIDEOMMEV2_MAX_NEW_TOKENS":128,"VIDEOMMEV2_MAX_NUM_BATCHED_TOKENS":65536,"VIDEOMMEV2_MIN_P":0.0,"VIDEOMMEV2_PRESENCE_PENALTY":0.0,"VIDEOMMEV2_PROMPT_MODE":"default","VIDEOMMEV2_TEMPERATURE":0.0,"VIDEOMMEV2_TOP_K":-1,"VIDEOMMEV2_TOP_P":1.0,"VIDEOMMEV2_VIDEO_MAX_PIXELS":262144,"VIDEOMMEV2_VIDEO_MIN_PIXELS":4096,"VIDEOMMEV2_VIDEO_TOTAL_PIXELS":0},"license":"See the upstream benchmark terms","media_paths":["media/video_qa/videommev2/videos","media/video_qa/videommev2/videos/test"],"preprocessing":{"batch_size":1,"enable_thinking":false,"fps":2,"gpu_memory_utilization":0.9,"max_frames":384,"max_model_len":65536,"max_new_tokens":128,"max_num_batched_tokens":65536,"min_p":0.0,"presence_penalty":0.0,"prompt_mode":"default","temperature":0.0,"top_k":-1,"top_p":1.0,"video_max_pixels":262144,"video_min_pixels":4096,"video_total_pixels":0},"redistribution_authorized":true,"schema_version":1,"source_url":"https://github.com/BradyFU/Video-MME","split":"test","task":"videommev2"}
+{"annotation_path":"annotations/spatial_intelligence/vsi/test.jsonl","artifact_paths":["artifacts/spatial_intelligence/vsi"],"benchmark":"vsi","evaluation":{"metric_profile":"vsi_official","parser_profile":"vsi_answer","prompt_profile":"vsi_default"},"expected_count":5130,"family":"spatial_intelligence","legacy_environment":{"VSI_BATCH_SIZE":16,"VSI_MAX_MODEL_LEN":32768,"VSI_MAX_NEW_TOKENS":1024,"VSI_SETTING":"video128-16M-video-f128-fps2-min65536-maxnone-total16777216-vsibench_preprocessed_128f_16M"},"license":"See the upstream benchmark terms","media_paths":["media/spatial_intelligence/vsi/videos","media/spatial_intelligence/vsi/videos/test"],"preprocessing":{"fps":2,"max_frames":128,"video_min_pixels":65536,"video_total_pixels":16777216},"redistribution_authorized":true,"schema_version":1,"source_url":"https://huggingface.co/datasets/nyu-visionx/VSI-Bench","split":"test","task":"vsi"}
diff --git a/code/docs/environment.md b/code/docs/environment.md
new file mode 100644
index 0000000000000000000000000000000000000000..2c96e61336151cc7de684c32812a037b7198c2b5
--- /dev/null
+++ b/code/docs/environment.md
@@ -0,0 +1,60 @@
+简体中文
+
+# Reproduce the OraRL Environment
+
+OraRL uses one reproducible software stack for policy optimization and
+task-native evaluation. The released configuration was validated on NVIDIA
+H20/Hopper GPUs with CUDA 12.9 and includes both runtimes in this repository.
+
+## Requirements
+
+- Linux with Conda available in `PATH`
+- NVIDIA GPU with a recent driver
+- CUDA 12.9-compatible hardware for the paper-matched environment
+
+CUDA 12.9 GA officially requires NVIDIA Linux driver 575.51.03 or newer.
+Other NVIDIA GPUs may work, but the published setup was validated on H20.
+
+## Install the pinned stack
+
+```bash
+bash scripts/create_conda_env.sh
+conda activate orarl
+
+python scripts/check_environment.py \
+ --require-gpu \
+ --model /path/to/local/model
+```
+
+The installer pins Python 3.11, PyTorch 2.10.0+cu129, Transformers 5.5.4,
+vLLM 0.19.1, FlashAttention 2.8.3, and the remaining packages in
+`requirements-cu129.txt`. The final editable install exposes the `orarl-*`
+commands and the bundled `verl` trainer package from this checkout; do not
+install an unrelated `verl` release from PyPI over it.
+
+## Validate an evaluation-only node
+
+If a node will only run evaluation, skip the training-side checks:
+
+```bash
+bash scripts/create_conda_env.sh --evaluation-only
+conda activate orarl
+
+python scripts/check_environment.py --evaluation-only
+```
+
+## Verify the release checkout
+
+Run the release checks before allocating a long job:
+
+```bash
+python scripts/check_release.py
+python -m pytest -q
+ruff check .
+```
+
+On a paper-matched H20 node, add `--require-gpu --require-h20` to
+`scripts/check_environment.py`. Use the same source revision and environment on
+every node of a distributed run.
+
+Continue with [Training](training.md) or [Evaluation](evaluation.md).
diff --git a/code/docs/environment_zh.md b/code/docs/environment_zh.md
new file mode 100644
index 0000000000000000000000000000000000000000..8f2ffa45fc1b407d729787d6156e5e38d493727c
--- /dev/null
+++ b/code/docs/environment_zh.md
@@ -0,0 +1,57 @@
+English
+
+# 复现 OraRL 环境
+
+OraRL 使用同一套可复现软件栈完成策略优化与任务原生评测。发布配置在 NVIDIA
+H20/Hopper 与 CUDA 12.9 上完成验证,本仓库同时包含两部分运行时。
+
+## 环境要求
+
+- `PATH` 中可用 Conda 的 Linux 系统
+- 驱动版本较新的 NVIDIA GPU
+- 复现论文环境时使用兼容 CUDA 12.9 的硬件
+
+CUDA 12.9 GA 官方要求 NVIDIA Linux 驱动不低于 575.51.03。其他 NVIDIA GPU
+也可能运行,但发布配置仅在 H20 上完成验证。
+
+## 安装固定版本软件栈
+
+```bash
+bash scripts/create_conda_env.sh
+conda activate orarl
+
+python scripts/check_environment.py \
+ --require-gpu \
+ --model /path/to/local/model
+```
+
+脚本会固定 Python 3.11、PyTorch 2.10.0+cu129、Transformers 5.5.4、
+vLLM 0.19.1、FlashAttention 2.8.3,以及 `requirements-cu129.txt` 中的其余依赖。
+最后一步的可编辑安装会同时提供 `orarl-*` 命令和本仓库自带的 `verl` 训练包;
+请勿再用 PyPI 上无关的 `verl` 版本覆盖它。
+
+## 验证仅用于评测的节点
+
+若某个节点只跑评测,可跳过训练侧检查:
+
+```bash
+bash scripts/create_conda_env.sh --evaluation-only
+conda activate orarl
+
+python scripts/check_environment.py --evaluation-only
+```
+
+## 验证发布源码
+
+分配长任务前先执行:
+
+```bash
+python scripts/check_release.py
+python -m pytest -q
+ruff check .
+```
+
+在与论文一致的 H20 节点上,可为 `scripts/check_environment.py` 增加
+`--require-gpu --require-h20`。多节点任务应在所有节点使用相同的源码版本和环境。
+
+随后参阅[训练](training_zh.md)或[评测](evaluation_zh.md)。
diff --git a/code/docs/evaluation.md b/code/docs/evaluation.md
new file mode 100644
index 0000000000000000000000000000000000000000..ee4d3f0efe618f9dbbb2cb6a5273eb78209cf144
--- /dev/null
+++ b/code/docs/evaluation.md
@@ -0,0 +1,121 @@
+简体中文
+
+# Evaluate Video-ORA
+
+OraRL evaluates direct, task-native answers with canonical profiles for the
+seven released task families. The corresponding runtime lives under
+`eval/task/`; first
+[validate the evaluation node](environment.md#validate-an-evaluation-only-node).
+
+## 1. Materialize model and data
+
+Install the Hugging Face CLI and download the released assets:
+
+```bash
+python -m pip install -U huggingface_hub
+
+hf download OraRL/Video-ORA-9B \
+ --local-dir "$PWD/models/Video-ORA-9B"
+
+hf download OraRL/OraRL-Data \
+ --repo-type dataset \
+ --include "OraRL-eval-data/**" \
+ --local-dir "$PWD/OraRL-Data"
+```
+
+The complete evaluation release is large. Downloads are resumable, and
+`OraRL-eval-data/assets.jsonl` is the authoritative file inventory.
+
+The local dataset layout is:
+
+```text
+OraRL-Data/OraRL-eval-data/
+├── datasets.jsonl # benchmark, prompt, parser, metric, and sampling profiles
+├── assets.jsonl # released-file inventory
+├── annotations/ # canonical JSONL rows
+└── media/ # raw images, videos, and subtitles
+```
+
+Derived preprocessing caches are intentionally excluded. Evaluators decode the
+declared raw media when no compatible cache is present.
+
+## 2. Run the canonical paper profile
+
+Preview the resolved command first:
+
+```bash
+orarl-eval \
+ --model "$PWD/models/Video-ORA-9B" \
+ --tasks paper \
+ --dataset "$PWD/OraRL-Data/OraRL-eval-data" \
+ --summary "$PWD/outputs/Video-ORA-9B/evaluation.json"
+```
+
+`orarl-eval` is a dry run by default. After checking the model, dataset,
+evaluator, task profiles, and output paths, add `--run`.
+
+To validate the pipeline with a bounded smoke test:
+
+```bash
+orarl-eval \
+ --model "$PWD/models/Video-ORA-9B" \
+ --tasks videomme \
+ --dataset "$PWD/OraRL-Data/OraRL-eval-data" \
+ --max-samples 8 \
+ --summary "$PWD/outputs/Video-ORA-9B/videomme-smoke.json" \
+ --run
+```
+
+Smoke scores only validate execution and must not be reported as benchmark
+results.
+
+## 3. Compose a task suite
+
+`--tasks paper` selects all released tasks. `--tasks video_qa` selects the
+seven Video QA benchmarks. Individual task names may be comma-separated.
+
+| Family | Task names |
+| --- | --- |
+| Video QA | `videomme`, `videommev2`, `mvbench`, `mmvu`, `videoholmes`, `longvideobench`, `mlvu` |
+| Spatial intelligence | `vsi`, `mmsi`, `mindcube`, `revsi` |
+| Temporal grounding | `temporal_grounding` |
+| Spatial grounding | `spatial_grounding` |
+| Tracking | `tracking` |
+| Spatial-temporal grounding | `stvg` |
+| Segmentation | `segmentation` |
+
+The canonical profiles in `datasets.jsonl` pin frame sampling, resolution,
+prompts, parsers, and metrics. Changing them defines a different evaluation
+setting. ReVSI is reported separately from the three-benchmark
+spatial-intelligence average.
+
+## 4. Add segmentation post-processing
+
+Segmentation inference runs without SAM2 post-processing by default. Enabling
+`--segmentation-run-sam2` additionally requires:
+
+- SAM2 weights (`SEGMENTATION_SAM2_CKPT`)
+- the matching Hydra config (`SEGMENTATION_SAM2_CFG`)
+- the official OneThinker `seg_post_sam2.py`
+ (`SEGMENTATION_POSTPROCESSOR_PATH`)
+- the `sam2` Python package
+
+OraRL validates all three paths before launch.
+
+## 5. Preserve reportable outputs
+
+Each task evaluator writes its native summary, then `orarl-eval` creates the
+requested aggregate JSON with requested, completed, and missing tasks, the
+return code, and official metrics. Outputs are grouped by paper task family
+under `outputs//`.
+
+For a reportable run, retain:
+
+- the OraRL source revision
+- the model and data revisions
+- the exact command and aggregate summary
+- software versions and accelerator type/count
+- every non-default profile or CLI override
+
+See [`../eval/README.md`](../eval/README.md) for the evaluator map, video
+decoding backend, and checkpoint-format notes.
diff --git a/code/docs/evaluation_zh.md b/code/docs/evaluation_zh.md
new file mode 100644
index 0000000000000000000000000000000000000000..145efdb90d5287196cfba0f44904212674c6d1bd
--- /dev/null
+++ b/code/docs/evaluation_zh.md
@@ -0,0 +1,116 @@
+English
+
+# 评测 Video-ORA
+
+OraRL 使用七个发布任务族的规范配置评测任务原生答案,对应运行时位于
+`eval/task/`。运行前请先
+[验证评测节点](environment_zh.md#验证仅用于评测的节点)。
+
+## 1. 准备模型和数据
+
+安装 Hugging Face CLI 并下载发布文件:
+
+```bash
+python -m pip install -U huggingface_hub
+
+hf download OraRL/Video-ORA-9B \
+ --local-dir "$PWD/models/Video-ORA-9B"
+
+hf download OraRL/OraRL-Data \
+ --repo-type dataset \
+ --include "OraRL-eval-data/**" \
+ --local-dir "$PWD/OraRL-Data"
+```
+
+完整评测数据体积较大,下载支持断点续传;
+`OraRL-eval-data/assets.jsonl` 是权威文件清单。
+
+本地数据结构为:
+
+```text
+OraRL-Data/OraRL-eval-data/
+├── datasets.jsonl # benchmark、prompt、parser、metric 与采样配置
+├── assets.jsonl # 发布文件清单
+├── annotations/ # 规范化 JSONL 标注
+└── media/ # 原始图像、视频和字幕
+```
+
+发布数据有意排除衍生预处理缓存;不存在兼容缓存时,评测器会直接解码清单中声明的
+原始媒体。
+
+## 2. 运行论文规范配置
+
+先预览解析后的命令:
+
+```bash
+orarl-eval \
+ --model "$PWD/models/Video-ORA-9B" \
+ --tasks paper \
+ --dataset "$PWD/OraRL-Data/OraRL-eval-data" \
+ --summary "$PWD/outputs/Video-ORA-9B/evaluation.json"
+```
+
+`orarl-eval` 默认为 dry run。检查模型、数据、评测器、任务配置和输出路径后,
+增加 `--run`。
+
+可先运行限制样本数的 smoke test:
+
+```bash
+orarl-eval \
+ --model "$PWD/models/Video-ORA-9B" \
+ --tasks videomme \
+ --dataset "$PWD/OraRL-Data/OraRL-eval-data" \
+ --max-samples 8 \
+ --summary "$PWD/outputs/Video-ORA-9B/videomme-smoke.json" \
+ --run
+```
+
+Smoke test 分数仅用于验证流程,不应作为 benchmark 结果报告。
+
+## 3. 组合任务集
+
+`--tasks paper` 选择所有发布任务,`--tasks video_qa` 选择 7 个视频问答
+benchmark;多个单项任务可用逗号分隔。
+
+| 任务族 | 任务名 |
+| --- | --- |
+| 视频问答 | `videomme`, `videommev2`, `mvbench`, `mmvu`, `videoholmes`, `longvideobench`, `mlvu` |
+| 空间智能 | `vsi`, `mmsi`, `mindcube`, `revsi` |
+| 时序定位 | `temporal_grounding` |
+| 空间定位 | `spatial_grounding` |
+| 跟踪 | `tracking` |
+| 时空定位 | `stvg` |
+| 分割 | `segmentation` |
+
+`datasets.jsonl` 中的规范配置固定了帧采样、分辨率、prompt、parser 和 metric;
+修改这些值即代表不同的评测设置。ReVSI 单独报告,不计入三个 benchmark 的空间
+智能平均分。
+
+## 4. 添加分割后处理
+
+分割推理默认不执行 SAM2 后处理。启用 `--segmentation-run-sam2` 时还需要:
+
+- SAM2 权重(`SEGMENTATION_SAM2_CKPT`)
+- 匹配的 Hydra 配置(`SEGMENTATION_SAM2_CFG`)
+- OneThinker 官方 `seg_post_sam2.py`
+ (`SEGMENTATION_POSTPROCESSOR_PATH`)
+- `sam2` Python 包
+
+OraRL 会在启动前检查三个路径。
+
+## 5. 保存可报告的输出
+
+每个任务评测器先写入原生 summary,随后 `orarl-eval` 生成指定的聚合 JSON,
+其中包含请求、完成和缺失的任务、返回码以及官方 metric。输出按论文任务族组织在
+`outputs//` 下。
+
+正式报告结果时请保留:
+
+- OraRL 源码版本
+- 模型和数据版本
+- 完整命令与聚合 summary
+- 软件版本和加速卡类型/数量
+- 所有非默认配置或命令行覆盖项
+
+评测器映射、视频解码后端和 checkpoint 格式说明见
+[`../eval/README.md`](../eval/README.md)。
diff --git a/code/docs/training.md b/code/docs/training.md
new file mode 100644
index 0000000000000000000000000000000000000000..c4441baae6a13115dd415e36c580167fa8b5bb24
--- /dev/null
+++ b/code/docs/training.md
@@ -0,0 +1,162 @@
+简体中文
+
+# Train with OraRL
+
+This workflow turns licensed source records into an auditable GRPO or OraRL
+run. Complete the
+[pinned environment installation](environment.md#install-the-pinned-stack)
+first.
+
+`orarl-train` is the public launch boundary: it validates paths and overrides,
+resolves one of the released recipes, and then starts the trainer bundled with
+this checkout. No second runtime repository is required.
+
+## 1. Build the training manifest
+
+The prepared training-data release will be uploaded separately and is not
+included in this Git repository yet. Until it is available, obtain each
+annotation and media source under its upstream license, then copy the example
+manifest:
+
+```bash
+cp configs/data_sources.example.yaml ./data_sources.local.yaml
+```
+
+Replace every `../local_data` placeholder with a licensed local path. Relative
+paths are resolved from the manifest location. Each source declares its input
+annotations, task, family, quota, media root, and optional license/source-page
+metadata.
+
+The public example reproduces the 100,032-row paper mixture:
+
+| Family | Rows |
+| --- | ---: |
+| Temporal grounding | 20,096 |
+| Tracking | 13,952 |
+| Segmentation | 12,032 |
+| Spatial grounding | 7,040 |
+| Spatial-temporal grounding | 9,536 |
+| Video QA | 20,288 |
+| Spatial intelligence | 17,088 |
+
+Build the deterministic train/canary split:
+
+```bash
+orarl-prepare \
+ --config ./data_sources.local.yaml \
+ --output ./prepared/train.jsonl \
+ --require-media
+```
+
+This writes:
+
+```text
+prepared/
+├── train.jsonl
+├── train.canary.jsonl
+└── train.manifest.json
+```
+
+The builder validates local media, normalizes task records, enforces source
+quotas and per-media caps, removes duplicate prompt identities, excludes
+supplied benchmark identities, and keeps train/canary media disjoint. The audit
+manifest records counts, shortfalls, source metadata, and SHA-256 checksums.
+
+## 2. Choose GRPO or OraRL
+
+| Recipe | Model scale | Method |
+| --- | --- | --- |
+| `grpo_4b.yaml` | 4B | GRPO baseline |
+| `grpo_9b.yaml` | 9B | GRPO baseline |
+| `orarl_4b.yaml` | 4B | OraRL |
+| `orarl_9b.yaml` | 9B | OraRL |
+
+The paper defaults use 64 prompts per rollout/update batch and eight policy
+samples per prompt. The 100,032-row mixture therefore runs for 1,563 steps in
+one epoch.
+
+## 3. Preview, then launch
+
+Set paths to a compatible local base model and the prepared data:
+
+```bash
+MODEL_DIR=/path/to/local/base-model
+OUTPUT_DIR="$PWD/runs/orarl-4b"
+
+orarl-train \
+ --config orarl_4b.yaml \
+ --model "$MODEL_DIR" \
+ --train-data "$PWD/prepared/train.jsonl" \
+ --val-data "$PWD/prepared/train.canary.jsonl" \
+ --output "$OUTPUT_DIR" \
+ --nodes 1 \
+ --gpus-per-node 8
+```
+
+The command is a dry run by default. Inspect the resolved invocation, then add
+`--run` to start training. Use `--set KEY=VALUE` for an explicit config
+override; retain all overrides with the run artifacts.
+
+For a one-update smoke test:
+
+```bash
+orarl-train \
+ --config orarl_4b.yaml \
+ --model "$MODEL_DIR" \
+ --train-data "$PWD/prepared/train.jsonl" \
+ --val-data "$PWD/prepared/train.canary.jsonl" \
+ --output "$OUTPUT_DIR" \
+ --nodes 1 \
+ --gpus-per-node 8 \
+ --set trainer.max_steps=1 \
+ --run
+```
+
+Repeat with `grpo_4b.yaml` and a different output directory to validate the
+baseline. Use the matching model and recipe for 9B runs.
+
+## 4. Scale across nodes
+
+All nodes must see the same source, model, data, and output paths:
+
+```bash
+HOSTS=node-a,node-b \
+ bash scripts/launch_multinode.sh \
+ --gpus-per-node 8 \
+ -- \
+ --config "$PWD/configs/orarl_4b.yaml" \
+ --model "$MODEL_DIR" \
+ --train-data "$PWD/prepared/train.jsonl" \
+ --val-data "$PWD/prepared/train.canary.jsonl" \
+ --output "$OUTPUT_DIR"
+```
+
+The launcher is also a dry run unless its own `--run` is supplied before the
+`--` separator. SSH host-key checking is strict by default.
+
+## 5. Accept a run
+
+`scripts/smoke_training.sh` runs one GRPO update and one OraRL update with small
+batches and saves a checkpoint for each:
+
+```bash
+bash scripts/smoke_training.sh \
+ --model "$MODEL_DIR" \
+ --train-data "$PWD/prepared/train.jsonl" \
+ --val-data "$PWD/prepared/train.canary.jsonl" \
+ --size 4b \
+ --gpus-per-node 8
+```
+
+Add `--dry-run` to inspect the resolved commands without allocating GPUs.
+
+Before a full experiment, verify that:
+
+- GRPO and OraRL each complete one update with finite rewards, losses, gradient
+ norms, and selection metrics.
+- A checkpoint can be saved, reloaded, and used for another update.
+- Multi-node runs form the expected Ray cluster and complete one update.
+- The source revision, config, command, data-manifest checksum, environment
+ versions, accelerator type, and all overrides are retained.
+
+Use [Evaluation](evaluation.md) to evaluate an exported checkpoint.
diff --git a/code/docs/training_zh.md b/code/docs/training_zh.md
new file mode 100644
index 0000000000000000000000000000000000000000..103bf2bb94f4e56bb395cb2faeaece4d7b9cc5ff
--- /dev/null
+++ b/code/docs/training_zh.md
@@ -0,0 +1,151 @@
+English
+
+# 使用 OraRL 训练
+
+本流程将已获许可的源数据整理为可审计的 GRPO 或 OraRL 训练任务。请先
+[安装固定版本软件栈](environment_zh.md#安装固定版本软件栈)。
+
+`orarl-train` 是公开的训练入口:它会检查路径与覆盖项、解析发布配置,再启动
+本仓库自带的训练器,无需准备第二份运行时源码。
+
+## 1. 构建训练清单
+
+整理后的训练数据将稍后单独上传,目前尚未包含在本 Git 仓库中。在发布完成前,
+请按上游许可证自行获取标注和媒体文件,然后复制示例清单:
+
+```bash
+cp configs/data_sources.example.yaml ./data_sources.local.yaml
+```
+
+将所有 `../local_data` 占位符替换为已获许可的本地路径。相对路径以清单文件所在
+目录为基准。每个数据源需要声明标注文件、任务、任务族、配额和媒体根目录,并可
+记录许可证与来源页面。
+
+公开示例对应论文使用的 100,032 条训练混合:
+
+| 任务族 | 条数 |
+| --- | ---: |
+| 时序定位 | 20,096 |
+| 跟踪 | 13,952 |
+| 分割 | 12,032 |
+| 空间定位 | 7,040 |
+| 时空定位 | 9,536 |
+| 视频问答 | 20,288 |
+| 空间智能 | 17,088 |
+
+构建确定性的训练集和 canary 集:
+
+```bash
+orarl-prepare \
+ --config ./data_sources.local.yaml \
+ --output ./prepared/train.jsonl \
+ --require-media
+```
+
+输出结构为:
+
+```text
+prepared/
+├── train.jsonl
+├── train.canary.jsonl
+└── train.manifest.json
+```
+
+构建器会检查本地媒体、规范化任务记录、执行数据源配额和单媒体上限、去除重复
+prompt、排除给定评测集中的样本,并保证训练集与 canary 集媒体互斥。审计清单会
+记录计数、缺额、来源信息和 SHA-256 校验值。
+
+## 2. 选择 GRPO 或 OraRL
+
+| 配置 | 模型规模 | 方法 |
+| --- | --- | --- |
+| `grpo_4b.yaml` | 4B | GRPO baseline |
+| `grpo_9b.yaml` | 9B | GRPO baseline |
+| `orarl_4b.yaml` | 4B | OraRL |
+| `orarl_9b.yaml` | 9B | OraRL |
+
+论文默认每个 rollout/update batch 使用 64 个 prompt,每个 prompt 采样 8 个策略
+回复,因此 100,032 条混合数据在一个 epoch 中对应 1,563 步。
+
+## 3. 预览并启动
+
+设置兼容的本地基础模型和已准备的数据路径:
+
+```bash
+MODEL_DIR=/path/to/local/base-model
+OUTPUT_DIR="$PWD/runs/orarl-4b"
+
+orarl-train \
+ --config orarl_4b.yaml \
+ --model "$MODEL_DIR" \
+ --train-data "$PWD/prepared/train.jsonl" \
+ --val-data "$PWD/prepared/train.canary.jsonl" \
+ --output "$OUTPUT_DIR" \
+ --nodes 1 \
+ --gpus-per-node 8
+```
+
+命令默认只执行 dry run。检查解析后的调用后,增加 `--run` 开始训练。可通过
+`--set KEY=VALUE` 显式覆盖配置,并应随运行产物保留全部覆盖项。
+
+单步 smoke test:
+
+```bash
+orarl-train \
+ --config orarl_4b.yaml \
+ --model "$MODEL_DIR" \
+ --train-data "$PWD/prepared/train.jsonl" \
+ --val-data "$PWD/prepared/train.canary.jsonl" \
+ --output "$OUTPUT_DIR" \
+ --nodes 1 \
+ --gpus-per-node 8 \
+ --set trainer.max_steps=1 \
+ --run
+```
+
+再使用 `grpo_4b.yaml` 和独立输出目录验证 baseline。9B 任务需使用匹配的模型与
+配置。
+
+## 4. 扩展到多节点
+
+所有节点必须看到相同的源码、模型、数据和输出路径:
+
+```bash
+HOSTS=node-a,node-b \
+ bash scripts/launch_multinode.sh \
+ --gpus-per-node 8 \
+ -- \
+ --config "$PWD/configs/orarl_4b.yaml" \
+ --model "$MODEL_DIR" \
+ --train-data "$PWD/prepared/train.jsonl" \
+ --val-data "$PWD/prepared/train.canary.jsonl" \
+ --output "$OUTPUT_DIR"
+```
+
+启动器同样默认为 dry run;需要在 `--` 分隔符前加入其自身的 `--run`。SSH
+主机密钥检查默认使用严格模式。
+
+## 5. 验收一次运行
+
+`scripts/smoke_training.sh` 会以较小的 batch 分别跑一步 GRPO 和一步 OraRL,
+并各保存一个 checkpoint:
+
+```bash
+bash scripts/smoke_training.sh \
+ --model "$MODEL_DIR" \
+ --train-data "$PWD/prepared/train.jsonl" \
+ --val-data "$PWD/prepared/train.canary.jsonl" \
+ --size 4b \
+ --gpus-per-node 8
+```
+
+加上 `--dry-run` 可在不占用 GPU 的情况下检查解析后的命令。
+
+正式实验前确认:
+
+- GRPO 和 OraRL 均可完成一步更新,reward、loss、梯度范数和选择指标均为有限值。
+- checkpoint 可保存、重新加载并继续完成一步更新。
+- 多节点任务能组成预期的 Ray 集群并完成一步更新。
+- 保存源码版本、配置、命令、数据清单校验值、环境版本、加速卡类型和所有覆盖项。
+
+使用[评测文档](evaluation_zh.md)评估导出的 checkpoint。
diff --git a/code/environment.yml b/code/environment.yml
new file mode 100644
index 0000000000000000000000000000000000000000..15207b477701ce82aa1cdc5e49b048f1c8dd6c0c
--- /dev/null
+++ b/code/environment.yml
@@ -0,0 +1,18 @@
+# Base Conda layer. Use scripts/create_conda_env.sh so CUDA wheels and editable
+# packages are installed in the required order.
+name: orarl
+
+channels:
+ - nvidia
+ - conda-forge
+ - defaults
+
+dependencies:
+ - python=3.11
+ - pip
+ - setuptools>=77
+ - wheel
+ - packaging
+ - ninja
+ - ffmpeg=7
+ - cuda-toolkit=12.9
diff --git a/code/eval/README.md b/code/eval/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..65a36c4dab87a4d205b6cc6dcb3190592c985d80
--- /dev/null
+++ b/code/eval/README.md
@@ -0,0 +1,72 @@
+# Evaluation runtime
+
+`eval/task/` holds the evaluators that produce every number in the paper. They
+ship with this repository, so a single clone can reproduce the full suite.
+`orarl-eval` is the supported entry point: it resolves the canonical dataset
+layout into per-task environment variables and then runs `eval/task/eval.sh`.
+
+```bash
+orarl-eval \
+ --model /path/to/exported-model \
+ --tasks paper \
+ --dataset OraRL/OraRL-Data \
+ --summary ./evaluation.json \
+ --run
+```
+
+Without `--run` the command prints the resolved plan and exits, which is the
+fastest way to confirm data and model paths before allocating GPUs.
+`eval/task/eval.sh` can also be called directly when you want to bypass the
+canonical dataset resolution and set the per-task variables yourself.
+
+## Task map
+
+| Family | Benchmarks | Entry point |
+| --- | --- | --- |
+| Video QA | VideoMME, VideoMME-v2, MVBench, MMVU, Video-Holmes, LongVideoBench, MLVU | `eval_vllm.py` |
+| Spatial intelligence | VSI-Bench, MindCube, MMSI-Bench, ReVSI | `eval_vllm.py`, `mmsi/eval_mmsi_transformers.py`, `revsi/eval_revsi_vllm.py` |
+| Temporal grounding | TimeLens (Charades, ActivityNet, QVHighlights) | `temporal_grounding/eval_timelens_hf.py` |
+| Spatial grounding | RefCOCO, RefCOCO+, RefCOCOg | `spatial_grounding/eval_refcoco_vllm.py` |
+| Tracking | GOT-10k | `tracking/eval_tracking_vllm.py` |
+| Spatio-temporal grounding | STVG | `spatial_temporal_grounding/eval_stvg_vllm.py` |
+| Segmentation | RefCOCO series, MeViS, ReasonVOS | `segmentation/eval_seg_vllm.py` plus `segmentation/post_sam2.py` |
+
+`eval_prompt.py` is the single source of truth for prompts, and
+`canonical_data.py` adapts the canonical `ORARL_EVAL_*` layout for every
+evaluator. MMSI-Bench runs through Transformers rather than vLLM; the other
+families run through vLLM.
+
+## Assets you must obtain separately
+
+Annotations and media come from the `OraRL/OraRL-Data` dataset repository, and
+model weights come from the released checkpoints. Neither is vendored here.
+
+Segmentation additionally needs three inputs, all supplied by you under their
+upstream licenses:
+
+- SAM2 weights and the matching Hydra config, passed as
+ `SEGMENTATION_SAM2_CKPT` and `SEGMENTATION_SAM2_CFG`.
+- The official OneThinker `seg_post_sam2.py`, passed as
+ `SEGMENTATION_POSTPROCESSOR_PATH`. `segmentation/post_sam2.py` is a thin
+ wrapper that injects paths into it so mask metrics stay identical to the
+ upstream implementation.
+- The `sam2` Python package.
+
+`orarl-eval` refuses to start segmentation with `--segmentation-run-sam2`
+unless all three paths exist.
+
+## Video decoding
+
+Video evaluators pin the decord backend through `FORCE_QWENVL_VIDEO_READER`.
+`eval/task/qwenvl_decord_patch.py` replaces `qwen_vl_utils.fetch_video` so the
+backend is honoured across `qwen_vl_utils` releases that otherwise hard-code
+torchvision. The backend is not cosmetic: switching MeViS and ReasonVOS from
+torchcodec to decord moved MeViS J&F from 56.7 to 60.6. Set
+`SEGMENTATION_VIDEO_READER` to `torchcodec` or `torchvision` only when
+deliberately measuring that difference.
+
+## Checkpoint format
+
+Exported Hugging Face checkpoints run as-is. Pass `--force-merge` when the model
+path is a sharded FSDP actor directory; `eval/task/eval.sh` then merges it with
+`scripts/model_merger.py` before inference.
diff --git a/code/eval/task/canonical_data.py b/code/eval/task/canonical_data.py
new file mode 100644
index 0000000000000000000000000000000000000000..63f66ac4f25bbc9e864d05d05f991cf04571336a
--- /dev/null
+++ b/code/eval/task/canonical_data.py
@@ -0,0 +1,450 @@
+"""Dependency-light compatibility adapter for canonical OraRL evaluation rows."""
+
+from __future__ import annotations
+
+import copy
+import json
+import os
+import re
+from collections.abc import Mapping, Sequence
+from pathlib import Path
+from typing import Any
+
+
+_CANONICAL_FIELDS = {
+ "schema_version",
+ "eval_task",
+ "sample_id",
+ "benchmark",
+ "split",
+ "problem",
+ "answer",
+ "images",
+ "videos",
+ "problem_type",
+ "source",
+}
+_MEDIA_PATH_KEYS = (
+ "path",
+ "video",
+ "image",
+ "video_path",
+ "image_path",
+ "file_name",
+)
+_PATH_KEY_RE = re.compile(
+ r"(?:^|_)(?:artifact|artifacts|cfg|checkpoint|ckpt|dir|directory|file|"
+ r"files|image|images|media|path|paths|prefix|root|subtitle|"
+ r"subtitles|tensor|tensors|video|videos)$"
+)
+_GROUND_TRUTH_KEYS = (
+ "bbox",
+ "bboxes",
+ "boxes",
+ "coordinates",
+ "duration",
+ "end",
+ "frame",
+ "frame_id",
+ "frame_ids",
+ "gt_bbox",
+ "mask",
+ "masks",
+ "negative_points",
+ "normalized_solution",
+ "object",
+ "positive_points",
+ "query",
+ "segment",
+ "segmentation",
+ "span",
+ "spans",
+ "start",
+ "target",
+ "time",
+ "timestamp",
+ "timestamps",
+ "trajectory",
+)
+_ASSET_DIRECTORIES = frozenset({"artifacts", "media"})
+
+
+class CanonicalDataError(ValueError):
+ """Raised when a canonical row cannot be safely adapted."""
+
+
+def is_canonical_row(record: Any) -> bool:
+ """Return whether a mapping uses the canonical evaluation-row contract."""
+
+ return (
+ isinstance(record, Mapping)
+ and record.get("schema_version") == 1
+ and _CANONICAL_FIELDS.issubset(record)
+ )
+
+
+def _configured_root() -> Path | None:
+ value = os.environ.get("ORARL_EVAL_DATA_ROOT", "").strip()
+ if value:
+ return Path(value).expanduser().resolve()
+ manifest = os.environ.get("ORARL_EVAL_DATASETS_JSONL", "").strip()
+ if manifest:
+ return Path(manifest).expanduser().resolve().parent
+ return None
+
+
+def _configured_asset_root() -> Path | None:
+ value = os.environ.get("ORARL_EVAL_ASSET_ROOT", "").strip()
+ return Path(value).expanduser().resolve() if value else None
+
+
+def _infer_root(input_path: Path) -> Path | None:
+ configured = _configured_root()
+ if configured is not None:
+ return configured
+ for candidate in (input_path.parent, *input_path.parents):
+ if (candidate / "datasets.jsonl").is_file():
+ return candidate.resolve()
+ return None
+
+
+def canonical_dataset_record(
+ eval_task: str,
+ split: str,
+ root: str | os.PathLike[str] | None = None,
+) -> dict[str, Any] | None:
+ """Return one canonical ``datasets.jsonl`` record for a task split."""
+
+ repository_root = (
+ Path(root).expanduser().resolve() if root is not None else _configured_root()
+ )
+ if repository_root is None:
+ return None
+ manifest = repository_root / "datasets.jsonl"
+ if not manifest.is_file():
+ return None
+
+ normalized_split = str(split).strip().casefold().replace("-", "_")
+ matches: list[dict[str, Any]] = []
+ try:
+ with manifest.open("r", encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, start=1):
+ if not line.strip():
+ continue
+ try:
+ record = json.loads(line)
+ except json.JSONDecodeError as error:
+ raise CanonicalDataError(
+ f"{manifest}:{line_number}: invalid JSON: {error}"
+ ) from error
+ if not isinstance(record, Mapping):
+ raise CanonicalDataError(
+ f"{manifest}:{line_number}: dataset record must be an object"
+ )
+ record_task = str(record.get("task") or "").casefold()
+ record_split = str(record.get("split") or "").casefold()
+ if record_task == str(eval_task).casefold() and record_split == normalized_split:
+ matches.append(copy.deepcopy(dict(record)))
+ except OSError as error:
+ raise CanonicalDataError(f"cannot read canonical dataset manifest: {error}") from error
+ if len(matches) > 1:
+ raise CanonicalDataError(
+ f"duplicate canonical dataset profile for {eval_task}/{normalized_split}"
+ )
+ return matches[0] if matches else None
+
+
+def resolve_repository_path(
+ value: str,
+ root: str | os.PathLike[str],
+ asset_root: str | os.PathLike[str] | None = None,
+) -> str:
+ """Resolve one repository-relative canonical path without allowing escape."""
+
+ raw = str(value).strip()
+ if not raw:
+ raise CanonicalDataError("canonical repository path must be nonempty")
+ repository_root = Path(root).expanduser().resolve()
+ configured_asset_root = (
+ Path(asset_root).expanduser().resolve()
+ if asset_root is not None
+ else _configured_asset_root()
+ )
+ candidate = Path(raw).expanduser()
+ selected_root = repository_root
+ if (
+ not candidate.is_absolute()
+ and candidate.parts
+ and candidate.parts[0].casefold() in _ASSET_DIRECTORIES
+ and configured_asset_root is not None
+ ):
+ selected_root = configured_asset_root
+ resolved = (
+ candidate.resolve()
+ if candidate.is_absolute()
+ else (selected_root / candidate).resolve()
+ )
+ allowed_roots = [repository_root]
+ if configured_asset_root is not None:
+ allowed_roots.append(configured_asset_root)
+ try:
+ next(root for root in allowed_roots if resolved.is_relative_to(root))
+ except StopIteration as error:
+ raise CanonicalDataError(
+ f"canonical path resolves outside configured evaluation roots: {raw}"
+ ) from error
+ return os.path.normpath(str(resolved))
+
+
+def repository_relative_output_path(
+ value: str,
+ root: str | os.PathLike[str],
+) -> str:
+ """Return a ``./``-prefixed path when an asset is contained by ``root``."""
+
+ raw = str(value).strip()
+ if not raw or not str(root).strip():
+ return raw
+ candidate = Path(raw).expanduser()
+ if not candidate.is_absolute():
+ return raw
+ repository_root = Path(root).expanduser().resolve()
+ resolved = candidate.resolve()
+ try:
+ relative = resolved.relative_to(repository_root)
+ except ValueError:
+ return raw
+ return f"./{relative.as_posix()}"
+
+
+def _is_path_key(value: object) -> bool:
+ return isinstance(value, str) and bool(_PATH_KEY_RE.search(value.casefold()))
+
+
+def _resolve_media_entry(value: Any, root: Path, asset_root: Path | None) -> Any:
+ if isinstance(value, str):
+ return resolve_repository_path(value, root, asset_root)
+ if not isinstance(value, Mapping):
+ return copy.deepcopy(value)
+ resolved = copy.deepcopy(dict(value))
+ for key in _MEDIA_PATH_KEYS:
+ path = resolved.get(key)
+ if isinstance(path, str) and path.strip():
+ resolved[key] = resolve_repository_path(path, root, asset_root)
+ return resolved
+
+
+def _resolve_declared_paths(
+ value: Any,
+ root: Path,
+ asset_root: Path | None,
+ path_context: bool = False,
+) -> Any:
+ if isinstance(value, str):
+ return (
+ resolve_repository_path(value, root, asset_root)
+ if path_context
+ else value
+ )
+ if isinstance(value, Mapping):
+ return {
+ key: _resolve_declared_paths(
+ item,
+ root,
+ asset_root,
+ path_context=path_context or _is_path_key(key),
+ )
+ for key, item in value.items()
+ }
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ return [
+ _resolve_declared_paths(
+ item,
+ root,
+ asset_root,
+ path_context=path_context,
+ )
+ for item in value
+ ]
+ return copy.deepcopy(value)
+
+
+def _media_path(value: Any) -> str:
+ if isinstance(value, str):
+ return value
+ if isinstance(value, Mapping):
+ for key in _MEDIA_PATH_KEYS:
+ candidate = value.get(key)
+ if isinstance(candidate, str) and candidate:
+ return candidate
+ return ""
+
+
+def _declared_path_values(value: Any, path_context: bool = False) -> list[str]:
+ if isinstance(value, str):
+ return [value] if path_context else []
+ if isinstance(value, Mapping):
+ paths: list[str] = []
+ for key, item in value.items():
+ paths.extend(
+ _declared_path_values(
+ item,
+ path_context=path_context or _is_path_key(key),
+ )
+ )
+ return paths
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ paths = []
+ for item in value:
+ paths.extend(_declared_path_values(item, path_context=path_context))
+ return paths
+ return []
+
+
+def adapt_canonical_row(
+ record: Mapping[str, Any],
+ root: str | os.PathLike[str] | None = None,
+ asset_root: str | os.PathLike[str] | None = None,
+) -> dict[str, Any]:
+ """Resolve canonical paths and add non-destructive legacy aliases."""
+
+ if not is_canonical_row(record):
+ return copy.deepcopy(dict(record))
+ repository_root = (
+ Path(root).expanduser().resolve() if root is not None else _configured_root()
+ )
+ if repository_root is None:
+ raise CanonicalDataError(
+ "canonical rows require ORARL_EVAL_DATA_ROOT or an explicit root"
+ )
+ resolved_asset_root = (
+ Path(asset_root).expanduser().resolve()
+ if asset_root is not None
+ else _configured_asset_root()
+ )
+
+ adapted = copy.deepcopy(dict(record))
+ for field in ("images", "videos", "subtitles"):
+ values = adapted.get(field)
+ if isinstance(values, list):
+ adapted[field] = [
+ _resolve_media_entry(value, repository_root, resolved_asset_root)
+ for value in values
+ ]
+ for field in ("preprocessed", "task_payload", "metadata", "evaluation"):
+ value = adapted.get(field)
+ if isinstance(value, Mapping):
+ adapted[field] = _resolve_declared_paths(
+ value,
+ repository_root,
+ resolved_asset_root,
+ )
+
+ image_paths = [
+ path for value in adapted.get("images", []) if (path := _media_path(value))
+ ]
+ video_paths = [
+ path for value in adapted.get("videos", []) if (path := _media_path(value))
+ ]
+ subtitle_paths = [
+ path
+ for value in adapted.get("subtitles", [])
+ if (path := _media_path(value))
+ ]
+
+ adapted.setdefault("id", adapted["sample_id"])
+ adapted.setdefault("problem_id", adapted["sample_id"])
+ adapted.setdefault("question_id", adapted["sample_id"])
+ adapted.setdefault("question", adapted["problem"])
+ adapted.setdefault("prompt", adapted["problem"])
+ adapted.setdefault("ground_truth", adapted["answer"])
+ adapted.setdefault("solution", adapted["answer"])
+ adapted.setdefault("dataset", adapted["benchmark"])
+ adapted.setdefault("options", copy.deepcopy(adapted.get("choices", [])))
+ adapted.setdefault("image_list", image_paths)
+ if image_paths:
+ adapted.setdefault("image", image_paths[0])
+ adapted.setdefault("image_path", image_paths[0])
+ if video_paths:
+ adapted.setdefault("video", video_paths[0])
+ adapted.setdefault("video_path", video_paths[0])
+ if video_paths or image_paths:
+ adapted.setdefault("path", (video_paths or image_paths)[0])
+ if subtitle_paths:
+ adapted.setdefault("subtitle", subtitle_paths[0])
+ adapted.setdefault("subtitle_path", subtitle_paths[0])
+
+ metadata = adapted.get("metadata")
+ if isinstance(metadata, Mapping):
+ for key, value in metadata.items():
+ adapted.setdefault(str(key), copy.deepcopy(value))
+ payload = adapted.get("task_payload")
+ if isinstance(payload, Mapping):
+ for key, value in payload.items():
+ adapted.setdefault(str(key), copy.deepcopy(value))
+ if isinstance(adapted.get("answer"), Mapping):
+ for key in _GROUND_TRUTH_KEYS:
+ if key in adapted["answer"]:
+ adapted.setdefault(key, copy.deepcopy(adapted["answer"][key]))
+
+ preprocessed = adapted.get("preprocessed")
+ if isinstance(preprocessed, Mapping):
+ for key, value in preprocessed.items():
+ adapted.setdefault(str(key), copy.deepcopy(value))
+ paths = _declared_path_values(preprocessed)
+ if paths:
+ adapted.setdefault("preprocessed_video", paths[0])
+ adapted.setdefault("preprocessed_video_path", paths[0])
+
+ if video_paths:
+ adapted.setdefault("data_type", "video")
+ elif image_paths:
+ adapted.setdefault("data_type", "image")
+ adapted.setdefault("question_type", adapted["problem_type"])
+ adapted.setdefault("task_type", adapted["problem_type"])
+ return adapted
+
+
+def load_json_records(
+ path: str | os.PathLike[str],
+ *,
+ root: str | os.PathLike[str] | None = None,
+ asset_root: str | os.PathLike[str] | None = None,
+) -> list[dict[str, Any]]:
+ """Read JSON/JSONL and adapt only rows that use the canonical contract."""
+
+ input_path = Path(path)
+ text = input_path.read_text(encoding="utf-8")
+ if not text.strip():
+ records: list[Any] = []
+ else:
+ try:
+ payload = json.loads(text)
+ except json.JSONDecodeError:
+ records = [json.loads(line) for line in text.splitlines() if line.strip()]
+ else:
+ records = payload if isinstance(payload, list) else [payload]
+ if not all(isinstance(record, Mapping) for record in records):
+ raise CanonicalDataError(f"{input_path}: every record must be a JSON object")
+
+ repository_root = (
+ Path(root).expanduser().resolve()
+ if root is not None
+ else _infer_root(input_path)
+ )
+ return [
+ adapt_canonical_row(record, repository_root, asset_root)
+ if is_canonical_row(record)
+ else copy.deepcopy(dict(record))
+ for record in records
+ ]
+
+
+__all__ = [
+ "CanonicalDataError",
+ "adapt_canonical_row",
+ "canonical_dataset_record",
+ "is_canonical_row",
+ "load_json_records",
+ "resolve_repository_path",
+]
diff --git a/code/eval/task/eval.sh b/code/eval/task/eval.sh
new file mode 100644
index 0000000000000000000000000000000000000000..fa388c1e1c6f48ff534126fa65bbcce5b3d23e18
--- /dev/null
+++ b/code/eval/task/eval.sh
@@ -0,0 +1,1018 @@
+#!/bin/bash
+# Thin shell entry for evaluation.
+#
+# Supported TASKS: videomme,videommev2,videommmu,mmvu,mvbench,videoholmes,longvideobench,lvbench,mlvu,vsi,mmsi,mindcube,revsi,spatial_grounding,tracking,stvg,temporal_grounding,segmentation,all
+# Common env overrides:
+# MODEL_PATH=/path/to/model_or_verl_actor
+# TASKS=videomme,videommev2,vsi
+# GPUS=0,1,2,3,4,5,6,7
+# TP_SIZE=1
+# Prefer explicit CLI args for new runs, which avoids stale env variables:
+# bash eval/task/eval.sh --model /path/to/model --tasks videomme,videommev2,mvbench --gpus 0,1,2,3,4,5,6,7 --force-merge
+#
+# MODEL must be provided explicitly with --model.
+
+# # —— Video QA / MC ——
+# bash eval/task/eval.sh --model $MODEL --tasks mvbench --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks mmvu --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks videomme --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks videoholmes --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks longvideobench --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks mlvu --gpus $GPUS --force-merge
+
+# # —— Spatial intelligence (MC) ——
+# bash eval/task/eval.sh --model $MODEL --tasks vsi --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks mmsi --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks mindcube --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks revsi --gpus $GPUS --force-merge
+
+# # —— Grounding / tracking / stvg / temporal ——
+# bash eval/task/eval.sh --model $MODEL --tasks spatial_grounding --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks tracking --gpus $GPUS --force-merge
+# bash eval/task/eval.sh --model $MODEL --tasks stvg --gpus $GPUS --force-merge
+# charades(默认)
+# TIMELENS_DATASETS=charades-timelens TIMELENS_FPS=4 bash eval/task/eval.sh --model $MODEL --tasks temporal_grounding --gpus $GPUS --force-merge
+# TIMELENS_DATASETS=activitynet-timelens TIMELENS_FPS=2 bash eval/task/eval.sh --model $MODEL --tasks temporal_grounding --gpus $GPUS --force-merge
+# TIMELENS_DATASETS=qvhighlights-timelens TIMELENS_FPS=2 bash eval/task/eval.sh --model $MODEL --tasks temporal_grounding --gpus $GPUS --force-merge
+
+
+
+# # —— Segmentation(需要 SAM2 + OraRL 评测环境)——
+# EVAL_CONDA_ENV=orarl SEGMENTATION_RUN_SAM2=true \
+# bash eval/task/eval.sh --model $MODEL --tasks segmentation --gpus $GPUS --force-merge
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+DEFAULT_GPUS="0,1,2,3,4,5,6,7"
+
+usage() {
+ cat <<'EOF'
+Usage:
+ bash eval/task/eval.sh [options]
+
+Options:
+ --model PATH Model or checkpoint path.
+ --tasks LIST Comma-separated task list, e.g. videomme,videommev2,mvbench,mlvu.
+ --gpus LIST Comma-separated GPU ids, e.g. 0,1,2,3,4,5,6,7.
+ --tp-size N Tensor parallel size.
+ --shard-mode MODE Sharding mode passed to vLLM task runner.
+ --video-cache-size N Video cache size.
+ --videomme-max-new-tokens N
+ Max new tokens for VideoMME generation.
+ --force-merge Force merging FSDP actor checkpoints.
+ --skip-merge Reuse existing merged HF model for actor checkpoints.
+ --merged-model PATH Explicit merged HF output/input path for actor checkpoints.
+ --base-model PATH Base model path used when merging actor checkpoints.
+ --env NAME Conda env to activate. Default: keep current env.
+ --segmentation-run-sam2
+ Enable SAM2 post-processing for segmentation.
+ --help Show this help.
+
+Core launch settings intentionally ignore inherited environment variables to
+avoid stale MODEL_PATH/TASKS/GPUS/etc. Use explicit CLI options instead.
+EOF
+}
+
+# Avoid stale shell environment contaminating evaluation identity. Task-specific
+# tuning envs below are still supported, but core launch identity is CLI/defaults.
+unset MODEL_PATH TASKS GPUS TP_SIZE SHARD_MODE VIDEO_CACHE_SIZE
+unset FORCE_MERGE SKIP_MERGE MERGED_MODEL_PATH BASE_MODEL_PATH EVAL_CONDA_ENV
+
+while (($# > 0)); do
+ case "$1" in
+ --model|--model-path)
+ [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 1; }
+ MODEL_PATH="$2"
+ shift 2
+ ;;
+ --tasks)
+ [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 1; }
+ TASKS="$2"
+ shift 2
+ ;;
+ --gpus)
+ if [[ $# -ge 2 && "$2" != --* ]]; then
+ GPUS="$2"
+ shift 2
+ else
+ GPUS="$DEFAULT_GPUS"
+ shift
+ fi
+ ;;
+ --tp-size)
+ [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 1; }
+ TP_SIZE="$2"
+ shift 2
+ ;;
+ --shard-mode)
+ [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 1; }
+ SHARD_MODE="$2"
+ shift 2
+ ;;
+ --video-cache-size)
+ [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 1; }
+ VIDEO_CACHE_SIZE="$2"
+ shift 2
+ ;;
+ --videomme-max-new-tokens)
+ [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 1; }
+ VIDEOMME_MAX_NEW_TOKENS="$2"
+ shift 2
+ ;;
+ --force-merge)
+ FORCE_MERGE=1
+ shift
+ ;;
+ --skip-merge)
+ SKIP_MERGE=1
+ shift
+ ;;
+ --merged-model|--merged-model-path)
+ [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 1; }
+ MERGED_MODEL_PATH="$2"
+ shift 2
+ ;;
+ --base-model|--base-model-path)
+ [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 1; }
+ BASE_MODEL_PATH="$2"
+ shift 2
+ ;;
+ --env|--conda-env)
+ [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 1; }
+ EVAL_CONDA_ENV="$2"
+ shift 2
+ ;;
+ --segmentation-run-sam2)
+ SEGMENTATION_RUN_SAM2=true
+ shift
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ break
+ ;;
+ *)
+ echo "ERROR: unknown option '$1'" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+# Require an explicit model so stale machine-local defaults cannot contaminate
+# benchmark results.
+if [[ -z "${MODEL_PATH:-}" ]]; then
+ echo "ERROR: --model PATH is required." >&2
+ usage >&2
+ exit 2
+fi
+
+MODEL_PATH="$(printf '%s' "$MODEL_PATH" | xargs)"
+
+# Common launch defaults.
+: "${TASKS:=all}"
+: "${GPUS:=$DEFAULT_GPUS}"
+: "${TP_SIZE:=1}"
+: "${SHARD_MODE:=contiguous}"
+: "${VIDEO_CACHE_SIZE:=2}"
+: "${PREFETCH_BATCHES:=1}"
+: "${FORCE_MERGE:=0}"
+: "${SKIP_MERGE:=0}"
+: "${MERGE_LOG_DIR:=${PROJECT_DIR}/logs/qwen3.5/eval/merge}"
+
+if [[ ! "$GPUS" =~ ^[0-9]+(,[0-9]+)*$ ]]; then
+ echo "ERROR: --gpus must be a comma-separated list of GPU ids, got '${GPUS}'" >&2
+ exit 1
+fi
+
+# VideoMME defaults.
+: "${VIDEOMME_VIDEO_BASE:=${PROJECT_DIR}/data/eval/videomme}"
+: "${VIDEOMME_VIDEO_DIR:=${VIDEOMME_VIDEO_BASE}/videos}"
+: "${VIDEOMME_VIDEO_MIN_PIXELS:=4096}"
+: "${VIDEOMME_VIDEO_MAX_PIXELS:=262144}"
+: "${VIDEOMME_VIDEO_TOTAL_PIXELS:=0}"
+: "${VIDEOMME_MAX_FRAMES:=384}"
+: "${VIDEOMME_FPS:=2}"
+: "${VIDEOMME_SETTING:=all-qwen3_vl-sub0-f${VIDEOMME_MAX_FRAMES}-fps${VIDEOMME_FPS}-min${VIDEOMME_VIDEO_MIN_PIXELS}-max${VIDEOMME_VIDEO_MAX_PIXELS}-total${VIDEOMME_VIDEO_TOTAL_PIXELS}-videomme_preprocessed_384f_262k_total0}"
+: "${VIDEOMME_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/videomme_preprocessed_384f_262k_total0.jsonl}"
+: "${VIDEOMME_PREPROCESSED_VIDEO_DIR:=${VIDEOMME_VIDEO_BASE}/preprocessed_videos_384f_262k_total0}"
+: "${VIDEOMME_BATCH_SIZE:=1}"
+: "${VIDEOMME_MAX_MODEL_LEN:=65536}"
+: "${VIDEOMME_MAX_NEW_TOKENS:=128}"
+: "${VIDEOMME_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${VIDEOMME_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${VIDEOMME_MAX_SAMPLES:=0}"
+
+# VideoMME-V2 defaults. Uses the same local schema and evaluator as VideoMME.
+: "${VIDEOMMEV2_VIDEO_BASE:=${PROJECT_DIR}/data/eval/videommev2}"
+: "${VIDEOMMEV2_VIDEO_DIR:=${VIDEOMMEV2_VIDEO_BASE}/videos}"
+: "${VIDEOMMEV2_VIDEO_MIN_PIXELS:=4096}"
+: "${VIDEOMMEV2_VIDEO_MAX_PIXELS:=262144}"
+: "${VIDEOMMEV2_VIDEO_TOTAL_PIXELS:=0}"
+: "${VIDEOMMEV2_MAX_FRAMES:=384}"
+: "${VIDEOMMEV2_FPS:=2}"
+: "${VIDEOMMEV2_SETTING:=all-qwen3_vl-sub0-f${VIDEOMMEV2_MAX_FRAMES}-fps${VIDEOMMEV2_FPS}-min${VIDEOMMEV2_VIDEO_MIN_PIXELS}-max${VIDEOMMEV2_VIDEO_MAX_PIXELS}-total${VIDEOMMEV2_VIDEO_TOTAL_PIXELS}-videommev2_preprocessed_384f_262k_total0}"
+: "${VIDEOMMEV2_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/videommev2_preprocessed_384f_262k_total0.jsonl}"
+: "${VIDEOMMEV2_PREPROCESSED_VIDEO_DIR:=${VIDEOMMEV2_VIDEO_BASE}/preprocessed_videos_384f_262k_total0}"
+: "${VIDEOMMEV2_BATCH_SIZE:=1}"
+: "${VIDEOMMEV2_MAX_MODEL_LEN:=65536}"
+: "${VIDEOMMEV2_MAX_NEW_TOKENS:=128}"
+: "${VIDEOMMEV2_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${VIDEOMMEV2_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${VIDEOMMEV2_PROMPT_MODE:=default}"
+: "${VIDEOMMEV2_ANSWER_FILTER:=}"
+: "${VIDEOMMEV2_MAX_SAMPLES:=0}"
+
+# VideoMMMU defaults.
+: "${VIDEOMMMU_SETTING:=videommmu-f128-fps2-min4096-max262144-total0-with-image}"
+: "${VIDEOMMMU_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/videommmu_with_image.json}"
+: "${VIDEOMMMU_VIDEO_ROOT:=${PROJECT_DIR}/data/eval/videommmu}"
+: "${VIDEOMMMU_BATCH_SIZE:=4}"
+: "${VIDEOMMMU_MAX_MODEL_LEN:=65536}"
+: "${VIDEOMMMU_MAX_NEW_TOKENS:=128}"
+: "${VIDEOMMMU_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${VIDEOMMMU_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${VIDEOMMMU_VIDEO_MIN_PIXELS:=4096}"
+: "${VIDEOMMMU_VIDEO_MAX_PIXELS:=262144}"
+: "${VIDEOMMMU_VIDEO_TOTAL_PIXELS:=0}"
+: "${VIDEOMMMU_MAX_FRAMES:=128}"
+: "${VIDEOMMMU_FPS:=2}"
+# OneThinker-style Adaptation uses subject videos + PNGs extracted from
+# Adaptation/test-00000-of-00001.parquet. Run once before eval:
+# python3 eval/data/extract_videommmu_images.py
+# prompt_mode=onethink reproduces OneThinker's CoT prompt; pair it with a large
+# VIDEOMMMU_MAX_NEW_TOKENS (OneThinker uses 8192).
+: "${VIDEOMMMU_PROMPT_MODE:=default}"
+# Separate Adaptation image resolution cap (OneThinker uses 1024*32*32=1048576).
+: "${VIDEOMMMU_IMAGE_MAX_PIXELS:=1048576}"
+# Qwen3.5 native benchmarking: thinking on + recommended sampling + long output.
+# To reproduce the official VideoMMMU number, run with:
+# VIDEOMMMU_PROMPT_MODE=qwen VIDEOMMMU_ENABLE_THINKING=true
+# VIDEOMMMU_MAX_NEW_TOKENS=32768 VIDEOMMMU_MAX_MODEL_LEN=131072
+# VIDEOMMMU_TEMPERATURE=1.0 VIDEOMMMU_TOP_P=0.95 VIDEOMMMU_TOP_K=20 VIDEOMMMU_PRESENCE_PENALTY=1.5
+: "${VIDEOMMMU_ENABLE_THINKING:=false}"
+: "${VIDEOMMMU_TEMPERATURE:=0.0}"
+: "${VIDEOMMMU_TOP_P:=1.0}"
+: "${VIDEOMMMU_TOP_K:=-1}"
+: "${VIDEOMMMU_PRESENCE_PENALTY:=0.0}"
+: "${VIDEOMMMU_MIN_P:=0.0}"
+
+# MMVU defaults (multiple-choice subset, raw video).
+# Build the MC JSONL once: python scripts/build_mmvu_mc.py
+: "${MMVU_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/mmvu_mc.jsonl}"
+: "${MMVU_VIDEO_ROOT:=${PROJECT_DIR}/data/eval/mmvu}"
+: "${MMVU_BATCH_SIZE:=1}"
+: "${MMVU_MAX_MODEL_LEN:=65536}"
+: "${MMVU_MAX_NEW_TOKENS:=128}"
+: "${MMVU_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${MMVU_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${MMVU_VIDEO_MIN_PIXELS:=4096}"
+: "${MMVU_VIDEO_MAX_PIXELS:=262144}"
+: "${MMVU_VIDEO_TOTAL_PIXELS:=0}"
+: "${MMVU_MAX_FRAMES:=384}"
+: "${MMVU_FPS:=2}"
+: "${MMVU_PROMPT_MODE:=default}"
+# Qwen3.5 native: MMVU_PROMPT_MODE=qwen MMVU_ENABLE_THINKING=true with large
+# MMVU_MAX_NEW_TOKENS / sampling, mirroring VideoMMMU.
+: "${MMVU_ENABLE_THINKING:=false}"
+: "${MMVU_TEMPERATURE:=0.0}"
+: "${MMVU_TOP_P:=1.0}"
+: "${MMVU_TOP_K:=-1}"
+: "${MMVU_PRESENCE_PENALTY:=0.0}"
+: "${MMVU_MIN_P:=0.0}"
+: "${MMVU_MAX_SAMPLES:=0}"
+: "${MMVU_SETTING:=mmvu-mc-f${MMVU_MAX_FRAMES}-fps${MMVU_FPS}-min${MMVU_VIDEO_MIN_PIXELS}-max${MMVU_VIDEO_MAX_PIXELS}-total${MMVU_VIDEO_TOTAL_PIXELS}-${MMVU_PROMPT_MODE}}"
+
+# MVBench defaults (multi-task short-video multiple-choice).
+: "${MVBENCH_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/mvbench.json}"
+: "${MVBENCH_VIDEO_ROOT:=${PROJECT_DIR}/data/eval/mvbench}"
+: "${MVBENCH_BATCH_SIZE:=1}"
+: "${MVBENCH_MAX_MODEL_LEN:=65536}"
+: "${MVBENCH_MAX_NEW_TOKENS:=128}"
+: "${MVBENCH_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${MVBENCH_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${MVBENCH_VIDEO_MIN_PIXELS:=4096}"
+: "${MVBENCH_VIDEO_MAX_PIXELS:=262144}"
+: "${MVBENCH_VIDEO_TOTAL_PIXELS:=0}"
+: "${MVBENCH_MAX_FRAMES:=384}"
+: "${MVBENCH_FPS:=2}"
+: "${MVBENCH_PROMPT_MODE:=default}"
+: "${MVBENCH_ENABLE_THINKING:=false}"
+: "${MVBENCH_TEMPERATURE:=0.0}"
+: "${MVBENCH_TOP_P:=1.0}"
+: "${MVBENCH_TOP_K:=-1}"
+: "${MVBENCH_PRESENCE_PENALTY:=0.0}"
+: "${MVBENCH_MIN_P:=0.0}"
+: "${MVBENCH_MAX_SAMPLES:=0}"
+: "${MVBENCH_SETTING:=mvbench-f${MVBENCH_MAX_FRAMES}-fps${MVBENCH_FPS}-min${MVBENCH_VIDEO_MIN_PIXELS}-max${MVBENCH_VIDEO_MAX_PIXELS}-total${MVBENCH_VIDEO_TOTAL_PIXELS}-${MVBENCH_PROMPT_MODE}}"
+
+# Video-Holmes defaults (all multiple-choice, raw cropped videos).
+# Download: huggingface-cli download TencentARC/Video-Holmes --repo-type dataset
+# Build: python scripts/build_videoholmes.py
+: "${VIDEOHOLMES_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/videoholmes.jsonl}"
+: "${VIDEOHOLMES_VIDEO_ROOT:=${PROJECT_DIR}/data/eval/videoholmes}"
+: "${VIDEOHOLMES_BATCH_SIZE:=1}"
+: "${VIDEOHOLMES_MAX_MODEL_LEN:=65536}"
+: "${VIDEOHOLMES_MAX_NEW_TOKENS:=128}"
+: "${VIDEOHOLMES_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${VIDEOHOLMES_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${VIDEOHOLMES_VIDEO_MIN_PIXELS:=4096}"
+: "${VIDEOHOLMES_VIDEO_MAX_PIXELS:=262144}"
+: "${VIDEOHOLMES_VIDEO_TOTAL_PIXELS:=0}"
+: "${VIDEOHOLMES_MAX_FRAMES:=384}"
+: "${VIDEOHOLMES_FPS:=2}"
+: "${VIDEOHOLMES_PROMPT_MODE:=default}"
+# Official benchmark prompt is reasoning-style:
+# VIDEOHOLMES_PROMPT_MODE=holmes VIDEOHOLMES_ENABLE_THINKING=true VIDEOHOLMES_MAX_NEW_TOKENS=1024
+: "${VIDEOHOLMES_ENABLE_THINKING:=false}"
+: "${VIDEOHOLMES_TEMPERATURE:=0.0}"
+: "${VIDEOHOLMES_TOP_P:=1.0}"
+: "${VIDEOHOLMES_TOP_K:=-1}"
+: "${VIDEOHOLMES_PRESENCE_PENALTY:=0.0}"
+: "${VIDEOHOLMES_MIN_P:=0.0}"
+: "${VIDEOHOLMES_MAX_SAMPLES:=0}"
+: "${VIDEOHOLMES_SETTING:=videoholmes-f${VIDEOHOLMES_MAX_FRAMES}-fps${VIDEOHOLMES_FPS}-min${VIDEOHOLMES_VIDEO_MIN_PIXELS}-max${VIDEOHOLMES_VIDEO_MAX_PIXELS}-total${VIDEOHOLMES_VIDEO_TOTAL_PIXELS}-${VIDEOHOLMES_PROMPT_MODE}}"
+
+# LongVideoBench defaults. Main videos/subtitles repo is gated; after getting
+# access, download/extract to LONGVIDEOBENCH_VIDEO_ROOT with videos/ + subtitles/.
+# Build validation JSONL: python scripts/build_longvideobench.py
+: "${LONGVIDEOBENCH_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/longvideobench_val.jsonl}"
+: "${LONGVIDEOBENCH_VIDEO_ROOT:=${PROJECT_DIR}/data/eval/longvideobench}"
+: "${LONGVIDEOBENCH_SUBTITLE_ROOT:=$LONGVIDEOBENCH_VIDEO_ROOT}"
+: "${LONGVIDEOBENCH_USE_SUBTITLES:=true}"
+: "${LONGVIDEOBENCH_BATCH_SIZE:=1}"
+: "${LONGVIDEOBENCH_MAX_MODEL_LEN:=65536}"
+: "${LONGVIDEOBENCH_MAX_NEW_TOKENS:=128}"
+: "${LONGVIDEOBENCH_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${LONGVIDEOBENCH_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${LONGVIDEOBENCH_VIDEO_MIN_PIXELS:=4096}"
+: "${LONGVIDEOBENCH_VIDEO_MAX_PIXELS:=262144}"
+: "${LONGVIDEOBENCH_VIDEO_TOTAL_PIXELS:=0}"
+: "${LONGVIDEOBENCH_MAX_FRAMES:=384}"
+: "${LONGVIDEOBENCH_FPS:=2}"
+: "${LONGVIDEOBENCH_PROMPT_MODE:=default}"
+: "${LONGVIDEOBENCH_ENABLE_THINKING:=false}"
+: "${LONGVIDEOBENCH_TEMPERATURE:=0.0}"
+: "${LONGVIDEOBENCH_TOP_P:=1.0}"
+: "${LONGVIDEOBENCH_TOP_K:=-1}"
+: "${LONGVIDEOBENCH_PRESENCE_PENALTY:=0.0}"
+: "${LONGVIDEOBENCH_MIN_P:=0.0}"
+: "${LONGVIDEOBENCH_MAX_SAMPLES:=0}"
+: "${LONGVIDEOBENCH_SETTING:=longvideobench-val-f${LONGVIDEOBENCH_MAX_FRAMES}-fps${LONGVIDEOBENCH_FPS}-min${LONGVIDEOBENCH_VIDEO_MIN_PIXELS}-max${LONGVIDEOBENCH_VIDEO_MAX_PIXELS}-total${LONGVIDEOBENCH_VIDEO_TOTAL_PIXELS}-${LONGVIDEOBENCH_PROMPT_MODE}}"
+
+# LVBench defaults (long-video multiple-choice; independent from LongVideoBench).
+: "${LVBENCH_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/lvbench.json}"
+: "${LVBENCH_VIDEO_ROOT:=${PROJECT_DIR}/data/eval/lvbench}"
+: "${LVBENCH_BATCH_SIZE:=4}"
+: "${LVBENCH_MAX_MODEL_LEN:=65536}"
+: "${LVBENCH_MAX_NEW_TOKENS:=128}"
+: "${LVBENCH_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${LVBENCH_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${LVBENCH_VIDEO_MIN_PIXELS:=4096}"
+: "${LVBENCH_VIDEO_MAX_PIXELS:=262144}"
+: "${LVBENCH_VIDEO_TOTAL_PIXELS:=0}"
+: "${LVBENCH_MAX_FRAMES:=128}"
+: "${LVBENCH_FPS:=2}"
+: "${LVBENCH_PROMPT_MODE:=default}"
+: "${LVBENCH_ENABLE_THINKING:=false}"
+: "${LVBENCH_TEMPERATURE:=0.0}"
+: "${LVBENCH_TOP_P:=1.0}"
+: "${LVBENCH_TOP_K:=-1}"
+: "${LVBENCH_PRESENCE_PENALTY:=0.0}"
+: "${LVBENCH_MIN_P:=0.0}"
+: "${LVBENCH_SETTING:=lvbench-f${LVBENCH_MAX_FRAMES}-fps${LVBENCH_FPS}-min${LVBENCH_VIDEO_MIN_PIXELS}-max${LVBENCH_VIDEO_MAX_PIXELS}-total${LVBENCH_VIDEO_TOTAL_PIXELS}-${LVBENCH_PROMPT_MODE}}"
+
+# MLVU defaults (dev multiple-choice subset = 7 tasks, M-Avg).
+# Build the MC JSONL once: python scripts/build_mlvu_mc.py
+: "${MLVU_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/mlvu_mc.jsonl}"
+: "${MLVU_VIDEO_ROOT:=${PROJECT_DIR}/data/eval/mlvu}"
+: "${MLVU_BATCH_SIZE:=1}"
+: "${MLVU_MAX_MODEL_LEN:=65536}"
+: "${MLVU_MAX_NEW_TOKENS:=128}"
+: "${MLVU_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${MLVU_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${MLVU_VIDEO_MIN_PIXELS:=4096}"
+: "${MLVU_VIDEO_MAX_PIXELS:=262144}"
+: "${MLVU_VIDEO_TOTAL_PIXELS:=0}"
+: "${MLVU_MAX_FRAMES:=384}"
+: "${MLVU_FPS:=2}"
+: "${MLVU_PROMPT_MODE:=default}"
+: "${MLVU_ENABLE_THINKING:=false}"
+: "${MLVU_TEMPERATURE:=0.0}"
+: "${MLVU_TOP_P:=1.0}"
+: "${MLVU_TOP_K:=-1}"
+: "${MLVU_PRESENCE_PENALTY:=0.0}"
+: "${MLVU_MIN_P:=0.0}"
+: "${MLVU_MAX_SAMPLES:=0}"
+: "${MLVU_SETTING:=mlvu-mc-f${MLVU_MAX_FRAMES}-fps${MLVU_FPS}-min${MLVU_VIDEO_MIN_PIXELS}-max${MLVU_VIDEO_MAX_PIXELS}-total${MLVU_VIDEO_TOTAL_PIXELS}-${MLVU_PROMPT_MODE}}"
+
+# VSI-Bench defaults.
+: "${VSI_SETTING:=video128-16M-video-f128-fps2-min65536-maxnone-total16777216-vsibench_preprocessed_128f_16M}"
+: "${VSI_DATA_FILE:=${PROJECT_DIR}/eval/data/valid_data/vsibench_preprocessed_128f_16M.jsonl}"
+: "${VSI_PREPROCESSED_VIDEO_DIR:=${PROJECT_DIR}/data/eval/vsi/preprocessed_videos}"
+: "${VSI_BATCH_SIZE:=16}"
+: "${VSI_MAX_MODEL_LEN:=32768}"
+: "${VSI_MAX_NEW_TOKENS:=1024}"
+: "${VSI_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${VSI_EXPECTED_SAMPLES:=5130}"
+
+# MMSI-Bench defaults. Transformers is used so every image can be retained;
+# set MMSI_BACKEND=vllm and MMSI_MAX_IMAGES=8 for the legacy path.
+: "${MMSI_BACKEND:=transformers}"
+: "${MMSI_DATA_FILE:=${PROJECT_DIR}/data/eval/mmsi/MMSI_bench.tsv}"
+: "${MMSI_IMAGE_MIN_PIXELS:=4096}"
+: "${MMSI_IMAGE_MAX_PIXELS:=262144}"
+if [[ "${MMSI_BACKEND}" == "transformers" ]]; then
+ : "${MMSI_MAX_IMAGES:=0}"
+else
+ : "${MMSI_MAX_IMAGES:=8}"
+fi
+: "${MMSI_IMAGE_COUNT_TAG:=$([[ "${MMSI_MAX_IMAGES}" -le 0 ]] && echo all || echo "${MMSI_MAX_IMAGES}")}"
+: "${MMSI_SETTING:=${MMSI_BACKEND}-img-min${MMSI_IMAGE_MIN_PIXELS}-max${MMSI_IMAGE_MAX_PIXELS}-n${MMSI_IMAGE_COUNT_TAG}-all-MMSI_bench}"
+: "${MMSI_BATCH_SIZE:=16}"
+: "${MMSI_MAX_MODEL_LEN:=32768}"
+: "${MMSI_MAX_NEW_TOKENS:=1024}"
+: "${MMSI_MAX_NUM_BATCHED_TOKENS:=32768}"
+: "${MMSI_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${MMSI_MAX_SAMPLES:=0}"
+: "${MMSI_CATEGORY:=}"
+: "${MMSI_ENABLE_THINKING:=false}"
+: "${MMSI_ATTN_IMPLEMENTATION:=flash_attention_2}"
+if [[ -n "${MMSI_CATEGORY}" || "${MMSI_MAX_SAMPLES}" != "0" ]]; then
+ : "${MMSI_EXPECTED_SAMPLES:=0}"
+else
+ : "${MMSI_EXPECTED_SAMPLES:=1000}"
+fi
+
+# MindCube-Tiny defaults.
+: "${MINDCUBE_SETTING:=img-min4096-max262144-n4-official-tiny1050}"
+: "${MINDCUBE_DATA_FILE:=${PROJECT_DIR}/data/eval/mindcube_official/combined-00000-of-00001.parquet}"
+: "${MINDCUBE_MAX_IMAGES:=4}"
+: "${MINDCUBE_EXPECTED_SAMPLES:=1050}"
+: "${MINDCUBE_HF_CACHE_DIR:=}"
+: "${MINDCUBE_BATCH_SIZE:=16}"
+: "${MINDCUBE_MAX_MODEL_LEN:=32768}"
+: "${MINDCUBE_MAX_NEW_TOKENS:=1024}"
+: "${MINDCUBE_MAX_NUM_BATCHED_TOKENS:=32768}"
+: "${MINDCUBE_GPU_MEMORY_UTILIZATION:=0.90}"
+
+# ReVSI defaults. The all-frame/native-frame profile reproduces the 58.2 result
+# reported for Video-ORA-9B; ReVSI remains excluded from the three-benchmark
+# spatial-intelligence average.
+: "${REVSI_DATA_FILE:=${PROJECT_DIR}/data/eval/revsi/all_frame/test-00000-of-00001.parquet}"
+: "${REVSI_VIDEO_ROOT:=${PROJECT_DIR}/data/eval/revsi}"
+: "${REVSI_FRAME_BUDGET:=all}"
+: "${REVSI_MAX_FRAMES:=128}"
+: "${REVSI_EXACT_NFRAMES:=true}"
+: "${REVSI_FPS:=2}"
+: "${REVSI_VIDEO_MIN_PIXELS:=65536}"
+: "${REVSI_VIDEO_MAX_PIXELS:=}"
+: "${REVSI_VIDEO_TOTAL_PIXELS:=16777216}"
+: "${REVSI_BATCH_SIZE:=16}"
+: "${REVSI_MAX_MODEL_LEN:=32768}"
+: "${REVSI_MAX_NEW_TOKENS:=64}"
+: "${REVSI_GPU_MEMORY_UTILIZATION:=0.90}"
+: "${REVSI_MAX_SAMPLES:=0}"
+: "${REVSI_EXPECTED_SAMPLES:=6808}"
+: "${REVSI_TASK_FILTER:=}"
+: "${REVSI_ENABLE_THINKING:=false}"
+: "${REVSI_SETTING:=native-all-f${REVSI_MAX_FRAMES}-exact${REVSI_EXACT_NFRAMES}-fps${REVSI_FPS}-min${REVSI_VIDEO_MIN_PIXELS}-max${REVSI_VIDEO_MAX_PIXELS:-none}-total${REVSI_VIDEO_TOTAL_PIXELS}}"
+
+# Spatial grounding defaults, aligned to data/joint/sft_joint_all.jsonl.
+: "${SPATIAL_GROUNDING_DATASETS:=refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test}"
+: "${SPATIAL_GROUNDING_BENCH_DIR:=${PROJECT_DIR}/eval/task/spatial_grounding}"
+: "${SPATIAL_GROUNDING_IMAGE_ROOT:=${PROJECT_DIR}/data/eval/spatial_grounding/images}"
+: "${SPATIAL_GROUNDING_PROCESSOR_PATH:=$MODEL_PATH}"
+: "${SPATIAL_GROUNDING_PROMPT_STYLE:=qwen_native}"
+: "${SPATIAL_GROUNDING_COORD_SYSTEM:=norm1000}"
+: "${SPATIAL_GROUNDING_BBOX_SELECT:=first}"
+: "${SPATIAL_GROUNDING_MIN_TOKENS:=64}"
+: "${SPATIAL_GROUNDING_TOTAL_TOKENS:=1024}"
+: "${SPATIAL_GROUNDING_BATCH_SIZE:=64}"
+: "${SPATIAL_GROUNDING_MAX_MODEL_LEN:=32768}"
+: "${SPATIAL_GROUNDING_MAX_NEW_TOKENS:=1024}"
+: "${SPATIAL_GROUNDING_MAX_NUM_BATCHED_TOKENS:=32768}"
+: "${SPATIAL_GROUNDING_GPU_MEMORY_UTILIZATION:=0.85}"
+: "${SPATIAL_GROUNDING_ENABLE_THINKING:=false}"
+: "${SPATIAL_GROUNDING_MAX_SAMPLES:=0}"
+: "${SPATIAL_GROUNDING_SETTING:=spatial-grounding-${SPATIAL_GROUNDING_DATASETS//,/_}-${SPATIAL_GROUNDING_PROMPT_STYLE}-min${SPATIAL_GROUNDING_MIN_TOKENS}-total${SPATIAL_GROUNDING_TOTAL_TOKENS}-new${SPATIAL_GROUNDING_MAX_NEW_TOKENS}}"
+
+# Tracking defaults, aligned to data/joint/sft_joint_all.jsonl tracking videos.
+: "${TRACKING_DATASETS:=eval_got10k}"
+: "${TRACKING_BENCH_DIR:=${PROJECT_DIR}/data/eval/tracking}"
+: "${TRACKING_BASE_PREFIX:=$TRACKING_BENCH_DIR}"
+: "${TRACKING_PROCESSOR_PATH:=$MODEL_PATH}"
+: "${TRACKING_VIDEO_MIN_PIXELS:=4096}"
+: "${TRACKING_VIDEO_MAX_PIXELS:=786432}"
+: "${TRACKING_VIDEO_TOTAL_PIXELS:=8388608}"
+: "${TRACKING_MAX_FRAMES:=32}"
+: "${TRACKING_FPS:=1}"
+: "${TRACKING_MAX_MODEL_LEN:=32768}"
+: "${TRACKING_MAX_NEW_TOKENS:=8192}"
+: "${TRACKING_MAX_NUM_BATCHED_TOKENS:=32768}"
+: "${TRACKING_BATCH_SIZE:=64}"
+: "${TRACKING_GPU_MEMORY_UTILIZATION:=0.95}"
+: "${TRACKING_ENABLE_THINKING:=false}"
+: "${TRACKING_PROMPT_MODE:=default}"
+: "${TRACKING_CHUNKED_REPROMPT:=0}"
+: "${TRACKING_REPROMPT_USE_GT_FIRST_BOX:=0}"
+: "${TRACKING_MAX_SAMPLES:=0}"
+: "${TRACKING_SETTING:=tracking-${TRACKING_DATASETS//,/_}-f${TRACKING_MAX_FRAMES}-fps${TRACKING_FPS}-min${TRACKING_VIDEO_MIN_PIXELS}-max${TRACKING_VIDEO_MAX_PIXELS}-total${TRACKING_VIDEO_TOTAL_PIXELS}-new${TRACKING_MAX_NEW_TOKENS}}"
+
+# STVG defaults, aligned to data/joint/sft_joint_all.jsonl stvg videos.
+: "${STVG_DATASETS:=eval_stvg}"
+: "${STVG_BENCH_DIR:=${PROJECT_DIR}/data/eval/stvg}"
+: "${STVG_BASE_PREFIX:=$STVG_BENCH_DIR}"
+: "${STVG_PROCESSOR_PATH:=$MODEL_PATH}"
+: "${STVG_VIDEO_MIN_PIXELS:=65536}"
+: "${STVG_VIDEO_MAX_PIXELS:=393216}"
+: "${STVG_VIDEO_TOTAL_PIXELS:=10485760}"
+: "${STVG_MAX_FRAMES:=128}"
+: "${STVG_FPS:=2}"
+: "${STVG_MAX_MODEL_LEN:=65536}"
+: "${STVG_MAX_NEW_TOKENS:=2048}"
+: "${STVG_MAX_NUM_BATCHED_TOKENS:=65536}"
+: "${STVG_BATCH_SIZE:=64}"
+: "${STVG_GPU_MEMORY_UTILIZATION:=0.85}"
+: "${STVG_ENABLE_THINKING:=false}"
+: "${STVG_PROMPT_MODE:=train_stvg}"
+: "${STVG_MAX_SAMPLES:=0}"
+: "${STVG_SETTING:=stvg-${STVG_DATASETS//,/_}-f${STVG_MAX_FRAMES}-fps${STVG_FPS}-min${STVG_VIDEO_MIN_PIXELS}-max${STVG_VIDEO_MAX_PIXELS}-total${STVG_VIDEO_TOTAL_PIXELS}-new${STVG_MAX_NEW_TOKENS}-${STVG_PROMPT_MODE}}"
+
+# Segmentation defaults.
+# This task is dispatched to eval/task/segmentation/run_eval_vllm.sh,
+# which performs vLLM inference plus optional SAM2 post-processing.
+: "${SEGMENTATION_DATASETS:=eval_seg_refcoco,eval_seg_refcocop,eval_seg_refcocog,eval_seg_mevis,eval_seg_reasonvos}"
+: "${SEGMENTATION_BENCH_DIR:=${PROJECT_DIR}/data/eval/segmentation}"
+: "${SEGMENTATION_DATA_ROOT:=$SEGMENTATION_BENCH_DIR}"
+: "${SEGMENTATION_PROCESSOR_PATH:=$MODEL_PATH}"
+: "${SEGMENTATION_DATA_TYPE:=all}"
+# train_seg mirrors the prompt used in joint SFT training data
+# (data/train/seg_image_nothink_evalaware.jsonl, seg_video_nothink.jsonl).
+: "${SEGMENTATION_PROMPT_MODE:=train_seg}"
+: "${SEGMENTATION_ENABLE_THINKING:=false}"
+: "${SEGMENTATION_BATCH_SIZE:=16}"
+: "${SEGMENTATION_MAX_NEW_TOKENS:=1024}"
+: "${SEGMENTATION_MAX_MODEL_LEN:=32768}"
+: "${SEGMENTATION_MAX_PIXELS_IMAGE:=1048576}"
+: "${SEGMENTATION_MIN_PIXELS_IMAGE:=4096}"
+: "${SEGMENTATION_VIDEO_MAX_PIXELS:=262144}"
+: "${SEGMENTATION_VIDEO_MIN_PIXELS:=4096}"
+: "${SEGMENTATION_VIDEO_TOTAL_PIXELS:=16777216}"
+: "${SEGMENTATION_MAX_FRAMES:=128}"
+: "${SEGMENTATION_FPS:=2}"
+: "${SEGMENTATION_VIDEO_READER:=decord}"
+: "${SEGMENTATION_GPU_MEM_UTIL:=0.85}"
+: "${SEGMENTATION_SEED:=42}"
+: "${SEGMENTATION_MAX_SAMPLES:=}"
+: "${SEGMENTATION_RUN_SAM2:=false}"
+: "${SEGMENTATION_SAM2_CKPT:=${PROJECT_DIR}/models/sam2/sam2.1_hiera_large.pt}"
+: "${SEGMENTATION_SAM2_CFG:=configs/sam2.1/sam2.1_hiera_l.yaml}"
+: "${SEGMENTATION_POSTPROCESSOR_PATH:=${PROJECT_DIR}/third_party/OneThinker/Evaluation/Eval/seg_post_sam2.py}"
+# SAM2 post-processing parallelism. world_size = NUM_GPUS * WORKERS_PER_GPU.
+# With a large SAM2_EPOCH_SIZE (single epoch) + maxtasksperchild=1, EACH worker
+# loads the SAM2 model exactly ONCE at startup and then streams its whole slice
+# (no repeated reloads). So WORKERS_PER_GPU only trades GPU utilization vs total
+# process count: too high (32 -> 256 procs) exhausts CPU/RAM/ffmpeg/handles and
+# aborts; too low (1) underutilizes the GPU. 4 per GPU is a balance (8 GPUs -> 32
+# persistent workers, 32 one-time loads); bump to 6-8 if GPU is still not full.
+# Leave NUM_GPUS empty to auto-use all visible GPUs.
+: "${SEGMENTATION_SAM2_NUM_GPUS:=}"
+: "${SEGMENTATION_SAM2_WORKERS_PER_GPU:=8}"
+: "${SEGMENTATION_SETTING:=segmentation-${SEGMENTATION_DATASETS//,/_}-${SEGMENTATION_PROMPT_MODE}-f${SEGMENTATION_MAX_FRAMES}-fps${SEGMENTATION_FPS}-min${SEGMENTATION_VIDEO_MIN_PIXELS}-max${SEGMENTATION_VIDEO_MAX_PIXELS}-total${SEGMENTATION_VIDEO_TOTAL_PIXELS}-new${SEGMENTATION_MAX_NEW_TOKENS}}"
+
+# TimeLens-Bench temporal grounding defaults.
+# This task uses HuggingFace transformers, not vLLM.
+: "${TIMELENS_DATASETS:=charades-timelens}"
+: "${TIMELENS_BENCH_DIR:=${PROJECT_DIR}/data/eval/temporal_grounding}"
+: "${TIMELENS_ENABLE_THINKING:=false}"
+: "${TIMELENS_MIN_TOKENS:=1}"
+: "${TIMELENS_TOTAL_TOKENS:=128000}"
+: "${TIMELENS_MAX_PIXELS:=409600}"
+: "${TIMELENS_MAX_FRAMES:=2048}"
+: "${TIMELENS_FPS:=4}"
+: "${TIMELENS_MAX_NEW_TOKENS:=128}"
+: "${TIMELENS_REPETITION_PENALTY:=1.0}"
+: "${TIMELENS_STOP_AFTER_ANSWER:=true}"
+: "${TIMELENS_PROMPT_MODE:=same}"
+: "${TIMELENS_NUM_WORKERS:=2}"
+: "${TIMELENS_MAX_SAMPLES:=0}"
+
+export MODEL_PATH TASKS GPUS TP_SIZE SHARD_MODE VIDEO_CACHE_SIZE PREFETCH_BATCHES
+export FORCE_MERGE SKIP_MERGE MERGE_LOG_DIR
+export VIDEOMME_SETTING VIDEOMME_DATA_FILE VIDEOMME_VIDEO_BASE VIDEOMME_VIDEO_DIR
+export VIDEOMME_PREPROCESSED_VIDEO_DIR VIDEOMME_VIDEO_MIN_PIXELS VIDEOMME_VIDEO_MAX_PIXELS
+export VIDEOMME_VIDEO_TOTAL_PIXELS VIDEOMME_MAX_FRAMES VIDEOMME_FPS
+export VIDEOMME_BATCH_SIZE VIDEOMME_MAX_MODEL_LEN VIDEOMME_MAX_NEW_TOKENS
+export VIDEOMME_MAX_NUM_BATCHED_TOKENS VIDEOMME_GPU_MEMORY_UTILIZATION
+export VIDEOMME_MAX_SAMPLES
+export VIDEOMMEV2_SETTING VIDEOMMEV2_DATA_FILE VIDEOMMEV2_VIDEO_BASE VIDEOMMEV2_VIDEO_DIR
+export VIDEOMMEV2_PREPROCESSED_VIDEO_DIR VIDEOMMEV2_VIDEO_MIN_PIXELS VIDEOMMEV2_VIDEO_MAX_PIXELS
+export VIDEOMMEV2_VIDEO_TOTAL_PIXELS VIDEOMMEV2_MAX_FRAMES VIDEOMMEV2_FPS
+export VIDEOMMEV2_BATCH_SIZE VIDEOMMEV2_MAX_MODEL_LEN VIDEOMMEV2_MAX_NEW_TOKENS
+export VIDEOMMEV2_MAX_NUM_BATCHED_TOKENS VIDEOMMEV2_GPU_MEMORY_UTILIZATION
+export VIDEOMMEV2_PROMPT_MODE VIDEOMMEV2_ANSWER_FILTER VIDEOMMEV2_MAX_SAMPLES
+export VIDEOMMMU_SETTING VIDEOMMMU_DATA_FILE VIDEOMMMU_VIDEO_ROOT
+export VIDEOMMMU_BATCH_SIZE VIDEOMMMU_MAX_MODEL_LEN VIDEOMMMU_MAX_NEW_TOKENS
+export VIDEOMMMU_MAX_NUM_BATCHED_TOKENS VIDEOMMMU_GPU_MEMORY_UTILIZATION
+export VIDEOMMMU_VIDEO_MIN_PIXELS VIDEOMMMU_VIDEO_MAX_PIXELS VIDEOMMMU_VIDEO_TOTAL_PIXELS
+export VIDEOMMMU_MAX_FRAMES VIDEOMMMU_FPS VIDEOMMMU_PROMPT_MODE VIDEOMMMU_IMAGE_MAX_PIXELS
+export VIDEOMMMU_ENABLE_THINKING VIDEOMMMU_TEMPERATURE VIDEOMMMU_TOP_P VIDEOMMMU_TOP_K
+export VIDEOMMMU_PRESENCE_PENALTY VIDEOMMMU_MIN_P
+export MMVU_SETTING MMVU_DATA_FILE MMVU_VIDEO_ROOT MMVU_BATCH_SIZE
+export MMVU_MAX_MODEL_LEN MMVU_MAX_NEW_TOKENS MMVU_MAX_NUM_BATCHED_TOKENS
+export MMVU_GPU_MEMORY_UTILIZATION MMVU_VIDEO_MIN_PIXELS MMVU_VIDEO_MAX_PIXELS
+export MMVU_VIDEO_TOTAL_PIXELS MMVU_MAX_FRAMES MMVU_FPS MMVU_PROMPT_MODE
+export MMVU_ENABLE_THINKING MMVU_TEMPERATURE MMVU_TOP_P MMVU_TOP_K
+export MMVU_PRESENCE_PENALTY MMVU_MIN_P MMVU_MAX_SAMPLES
+export MVBENCH_SETTING MVBENCH_DATA_FILE MVBENCH_VIDEO_ROOT MVBENCH_BATCH_SIZE
+export MVBENCH_MAX_MODEL_LEN MVBENCH_MAX_NEW_TOKENS MVBENCH_MAX_NUM_BATCHED_TOKENS
+export MVBENCH_GPU_MEMORY_UTILIZATION MVBENCH_VIDEO_MIN_PIXELS MVBENCH_VIDEO_MAX_PIXELS
+export MVBENCH_VIDEO_TOTAL_PIXELS MVBENCH_MAX_FRAMES MVBENCH_FPS MVBENCH_PROMPT_MODE
+export MVBENCH_ENABLE_THINKING MVBENCH_TEMPERATURE MVBENCH_TOP_P MVBENCH_TOP_K
+export MVBENCH_PRESENCE_PENALTY MVBENCH_MIN_P MVBENCH_MAX_SAMPLES
+export VIDEOHOLMES_SETTING VIDEOHOLMES_DATA_FILE VIDEOHOLMES_VIDEO_ROOT VIDEOHOLMES_BATCH_SIZE
+export VIDEOHOLMES_MAX_MODEL_LEN VIDEOHOLMES_MAX_NEW_TOKENS VIDEOHOLMES_MAX_NUM_BATCHED_TOKENS
+export VIDEOHOLMES_GPU_MEMORY_UTILIZATION VIDEOHOLMES_VIDEO_MIN_PIXELS VIDEOHOLMES_VIDEO_MAX_PIXELS
+export VIDEOHOLMES_VIDEO_TOTAL_PIXELS VIDEOHOLMES_MAX_FRAMES VIDEOHOLMES_FPS VIDEOHOLMES_PROMPT_MODE
+export VIDEOHOLMES_ENABLE_THINKING VIDEOHOLMES_TEMPERATURE VIDEOHOLMES_TOP_P VIDEOHOLMES_TOP_K
+export VIDEOHOLMES_PRESENCE_PENALTY VIDEOHOLMES_MIN_P VIDEOHOLMES_MAX_SAMPLES
+export LONGVIDEOBENCH_SETTING LONGVIDEOBENCH_DATA_FILE LONGVIDEOBENCH_VIDEO_ROOT
+export LONGVIDEOBENCH_SUBTITLE_ROOT LONGVIDEOBENCH_USE_SUBTITLES LONGVIDEOBENCH_BATCH_SIZE
+export LONGVIDEOBENCH_MAX_MODEL_LEN LONGVIDEOBENCH_MAX_NEW_TOKENS LONGVIDEOBENCH_MAX_NUM_BATCHED_TOKENS
+export LONGVIDEOBENCH_GPU_MEMORY_UTILIZATION LONGVIDEOBENCH_VIDEO_MIN_PIXELS LONGVIDEOBENCH_VIDEO_MAX_PIXELS
+export LONGVIDEOBENCH_VIDEO_TOTAL_PIXELS LONGVIDEOBENCH_MAX_FRAMES LONGVIDEOBENCH_FPS LONGVIDEOBENCH_PROMPT_MODE
+export LONGVIDEOBENCH_ENABLE_THINKING LONGVIDEOBENCH_TEMPERATURE LONGVIDEOBENCH_TOP_P LONGVIDEOBENCH_TOP_K
+export LONGVIDEOBENCH_PRESENCE_PENALTY LONGVIDEOBENCH_MIN_P LONGVIDEOBENCH_MAX_SAMPLES
+export LVBENCH_SETTING LVBENCH_DATA_FILE LVBENCH_VIDEO_ROOT LVBENCH_BATCH_SIZE
+export LVBENCH_MAX_MODEL_LEN LVBENCH_MAX_NEW_TOKENS LVBENCH_MAX_NUM_BATCHED_TOKENS
+export LVBENCH_GPU_MEMORY_UTILIZATION LVBENCH_VIDEO_MIN_PIXELS LVBENCH_VIDEO_MAX_PIXELS
+export LVBENCH_VIDEO_TOTAL_PIXELS LVBENCH_MAX_FRAMES LVBENCH_FPS LVBENCH_PROMPT_MODE
+export LVBENCH_ENABLE_THINKING LVBENCH_TEMPERATURE LVBENCH_TOP_P LVBENCH_TOP_K
+export LVBENCH_PRESENCE_PENALTY LVBENCH_MIN_P
+export MLVU_SETTING MLVU_DATA_FILE MLVU_VIDEO_ROOT MLVU_BATCH_SIZE
+export MLVU_MAX_MODEL_LEN MLVU_MAX_NEW_TOKENS MLVU_MAX_NUM_BATCHED_TOKENS
+export MLVU_GPU_MEMORY_UTILIZATION MLVU_VIDEO_MIN_PIXELS MLVU_VIDEO_MAX_PIXELS
+export MLVU_VIDEO_TOTAL_PIXELS MLVU_MAX_FRAMES MLVU_FPS MLVU_PROMPT_MODE
+export MLVU_ENABLE_THINKING MLVU_TEMPERATURE MLVU_TOP_P MLVU_TOP_K
+export MLVU_PRESENCE_PENALTY MLVU_MIN_P MLVU_MAX_SAMPLES
+export VSI_SETTING VSI_DATA_FILE VSI_PREPROCESSED_VIDEO_DIR
+export VSI_BATCH_SIZE VSI_MAX_MODEL_LEN VSI_MAX_NEW_TOKENS VSI_GPU_MEMORY_UTILIZATION
+export VSI_EXPECTED_SAMPLES
+export MMSI_BACKEND MMSI_SETTING MMSI_DATA_FILE MMSI_MAX_IMAGES MMSI_BATCH_SIZE
+export MMSI_IMAGE_MIN_PIXELS MMSI_IMAGE_MAX_PIXELS MMSI_IMAGE_COUNT_TAG
+export MMSI_MAX_MODEL_LEN MMSI_MAX_NEW_TOKENS MMSI_MAX_NUM_BATCHED_TOKENS
+export MMSI_GPU_MEMORY_UTILIZATION MMSI_MAX_SAMPLES MMSI_CATEGORY
+export MMSI_ENABLE_THINKING MMSI_ATTN_IMPLEMENTATION MMSI_EXPECTED_SAMPLES
+export MINDCUBE_SETTING MINDCUBE_DATA_FILE MINDCUBE_MAX_IMAGES MINDCUBE_BATCH_SIZE
+export MINDCUBE_MAX_MODEL_LEN MINDCUBE_MAX_NEW_TOKENS MINDCUBE_MAX_NUM_BATCHED_TOKENS
+export MINDCUBE_GPU_MEMORY_UTILIZATION MINDCUBE_EXPECTED_SAMPLES MINDCUBE_HF_CACHE_DIR
+export REVSI_SETTING REVSI_DATA_FILE REVSI_VIDEO_ROOT REVSI_FRAME_BUDGET
+export REVSI_MAX_FRAMES REVSI_EXACT_NFRAMES REVSI_FPS
+export REVSI_VIDEO_MIN_PIXELS REVSI_VIDEO_MAX_PIXELS REVSI_VIDEO_TOTAL_PIXELS
+export REVSI_BATCH_SIZE REVSI_MAX_MODEL_LEN REVSI_MAX_NEW_TOKENS
+export REVSI_GPU_MEMORY_UTILIZATION REVSI_MAX_SAMPLES REVSI_EXPECTED_SAMPLES
+export REVSI_TASK_FILTER REVSI_ENABLE_THINKING
+export SPATIAL_GROUNDING_SETTING SPATIAL_GROUNDING_DATASETS SPATIAL_GROUNDING_BENCH_DIR
+export SPATIAL_GROUNDING_IMAGE_ROOT SPATIAL_GROUNDING_PROCESSOR_PATH SPATIAL_GROUNDING_PROMPT_STYLE
+export SPATIAL_GROUNDING_COORD_SYSTEM SPATIAL_GROUNDING_BBOX_SELECT
+export SPATIAL_GROUNDING_MIN_TOKENS SPATIAL_GROUNDING_TOTAL_TOKENS
+export SPATIAL_GROUNDING_BATCH_SIZE SPATIAL_GROUNDING_MAX_MODEL_LEN
+export SPATIAL_GROUNDING_MAX_NEW_TOKENS SPATIAL_GROUNDING_MAX_NUM_BATCHED_TOKENS
+export SPATIAL_GROUNDING_GPU_MEMORY_UTILIZATION SPATIAL_GROUNDING_ENABLE_THINKING
+export SPATIAL_GROUNDING_MAX_SAMPLES
+export TRACKING_SETTING TRACKING_DATASETS TRACKING_BENCH_DIR TRACKING_BASE_PREFIX
+export TRACKING_PROCESSOR_PATH TRACKING_VIDEO_MIN_PIXELS TRACKING_VIDEO_MAX_PIXELS
+export TRACKING_VIDEO_TOTAL_PIXELS TRACKING_MAX_FRAMES TRACKING_FPS
+export TRACKING_MAX_MODEL_LEN TRACKING_MAX_NEW_TOKENS TRACKING_MAX_NUM_BATCHED_TOKENS
+export TRACKING_BATCH_SIZE TRACKING_GPU_MEMORY_UTILIZATION TRACKING_ENABLE_THINKING
+export TRACKING_PROMPT_MODE TRACKING_CHUNKED_REPROMPT TRACKING_REPROMPT_USE_GT_FIRST_BOX
+export TRACKING_MAX_SAMPLES
+export STVG_SETTING STVG_DATASETS STVG_BENCH_DIR STVG_BASE_PREFIX STVG_PROCESSOR_PATH
+export STVG_VIDEO_MIN_PIXELS STVG_VIDEO_MAX_PIXELS STVG_VIDEO_TOTAL_PIXELS
+export STVG_MAX_FRAMES STVG_FPS STVG_MAX_MODEL_LEN STVG_MAX_NEW_TOKENS
+export STVG_MAX_NUM_BATCHED_TOKENS STVG_BATCH_SIZE STVG_GPU_MEMORY_UTILIZATION
+export STVG_ENABLE_THINKING STVG_PROMPT_MODE STVG_MAX_SAMPLES
+export SEGMENTATION_SETTING SEGMENTATION_DATASETS SEGMENTATION_BENCH_DIR SEGMENTATION_DATA_ROOT
+export SEGMENTATION_PROCESSOR_PATH SEGMENTATION_DATA_TYPE SEGMENTATION_PROMPT_MODE
+export SEGMENTATION_ENABLE_THINKING SEGMENTATION_BATCH_SIZE SEGMENTATION_MAX_NEW_TOKENS
+export SEGMENTATION_MAX_MODEL_LEN SEGMENTATION_MAX_PIXELS_IMAGE SEGMENTATION_MIN_PIXELS_IMAGE
+export SEGMENTATION_VIDEO_MAX_PIXELS SEGMENTATION_VIDEO_MIN_PIXELS SEGMENTATION_VIDEO_TOTAL_PIXELS
+export SEGMENTATION_MAX_FRAMES SEGMENTATION_FPS SEGMENTATION_VIDEO_READER
+export SEGMENTATION_GPU_MEM_UTIL SEGMENTATION_SEED
+export SEGMENTATION_MAX_SAMPLES
+export SEGMENTATION_RUN_SAM2 SEGMENTATION_SAM2_CKPT SEGMENTATION_SAM2_CFG
+export SEGMENTATION_SAM2_NUM_GPUS SEGMENTATION_SAM2_WORKERS_PER_GPU
+export TIMELENS_BENCH_DIR TIMELENS_MAX_SAMPLES
+
+# Conda env used to run eval. By default, keep the currently active environment.
+# Override with EVAL_CONDA_ENV=xxx only when an explicit env switch is desired.
+: "${EVAL_CONDA_ENV:=keep}"
+if [[ "${EVAL_CONDA_ENV}" != "keep" && "${CONDA_DEFAULT_ENV:-}" != "${EVAL_CONDA_ENV}" ]]; then
+ if command -v conda >/dev/null 2>&1; then
+ eval "$(conda shell.bash hook)"
+ conda activate "${EVAL_CONDA_ENV}"
+ else
+ echo "ERROR: conda not found and CONDA_DEFAULT_ENV=${CONDA_DEFAULT_ENV:-}" >&2
+ exit 1
+ fi
+fi
+
+cd "${PROJECT_DIR}"
+
+resolve_model_path() {
+ local input_path="${MODEL_PATH%/}"
+
+ if ls "${input_path}"/model_world_size_*_rank_0.pt >/dev/null 2>&1; then
+ local actor_dir="$input_path"
+ local hf_dir="${MERGED_MODEL_PATH:-${actor_dir}/huggingface}"
+ local need_merge=0
+
+ if [[ "${SKIP_MERGE}" = "1" ]]; then
+ need_merge=0
+ elif [[ "${FORCE_MERGE}" = "1" ]]; then
+ need_merge=1
+ elif [[ ! -f "${hf_dir}/model.safetensors" ]]; then
+ need_merge=1
+ fi
+
+ if [[ "${need_merge}" = "1" ]]; then
+ mkdir -p "${MERGE_LOG_DIR}"
+ local merge_log="${MERGE_LOG_DIR}/merge-$(basename "$(dirname "${actor_dir}")")-$(date +%Y%m%d_%H%M%S).log"
+ echo "[merge] FSDP actor checkpoint detected: ${actor_dir}"
+ echo "[merge] Merging shards -> ${hf_dir}"
+ if [[ -n "${BASE_MODEL_PATH:-}" ]]; then
+ python -u scripts/model_merger.py \
+ --local_dir "${actor_dir}" \
+ --base_model_path "${BASE_MODEL_PATH}" \
+ 2>&1 | tee "${merge_log}"
+ else
+ python -u scripts/model_merger.py \
+ --local_dir "${actor_dir}" \
+ 2>&1 | tee "${merge_log}"
+ fi
+ if [[ ! -f "${hf_dir}/model.safetensors" ]]; then
+ echo "ERROR: merge finished but ${hf_dir}/model.safetensors was not created. Log: ${merge_log}" >&2
+ exit 1
+ fi
+ else
+ echo "[merge] Using existing merged HF model: ${hf_dir}"
+ fi
+
+ MODEL_PATH="${hf_dir}"
+ export MODEL_PATH
+
+ if [[ "${TRACKING_PROCESSOR_PATH%/}" = "${input_path}" ]]; then
+ TRACKING_PROCESSOR_PATH="${MODEL_PATH}"
+ export TRACKING_PROCESSOR_PATH
+ fi
+ if [[ "${STVG_PROCESSOR_PATH%/}" = "${input_path}" ]]; then
+ STVG_PROCESSOR_PATH="${MODEL_PATH}"
+ export STVG_PROCESSOR_PATH
+ fi
+ if [[ "${SPATIAL_GROUNDING_PROCESSOR_PATH%/}" = "${input_path}" ]]; then
+ SPATIAL_GROUNDING_PROCESSOR_PATH="${MODEL_PATH}"
+ export SPATIAL_GROUNDING_PROCESSOR_PATH
+ fi
+ if [[ "${SEGMENTATION_PROCESSOR_PATH%/}" = "${input_path}" ]]; then
+ SEGMENTATION_PROCESSOR_PATH="${MODEL_PATH}"
+ export SEGMENTATION_PROCESSOR_PATH
+ fi
+ elif [[ -d "${input_path}/huggingface" && -f "${input_path}/huggingface/model.safetensors" && ! -f "${input_path}/config.json" ]]; then
+ MODEL_PATH="${input_path}/huggingface"
+ export MODEL_PATH
+ echo "[merge] MODEL_PATH points to a checkpoint wrapper; using ${MODEL_PATH}"
+ fi
+}
+
+resolve_model_path
+
+VLLM_TASKS=""
+RUN_TIMELENS=0
+RUN_SEGMENTATION=0
+RUN_MMSI_TRANSFORMERS=0
+RUN_REVSI=0
+
+if [[ "${MMSI_BACKEND}" != "transformers" && "${MMSI_BACKEND}" != "vllm" ]]; then
+ echo "ERROR: MMSI_BACKEND must be 'transformers' or 'vllm', got '${MMSI_BACKEND}'" >&2
+ exit 1
+fi
+
+append_vllm_task() {
+ local task="$1"
+ if [[ -z "$VLLM_TASKS" ]]; then
+ VLLM_TASKS="$task"
+ else
+ VLLM_TASKS="${VLLM_TASKS},${task}"
+ fi
+}
+
+model_family_tag() {
+ local model_path="${1%/}"
+ local name
+ name="$(basename "$model_path")"
+ local parent
+ local grandparent
+ parent="$(basename "$(dirname "$model_path")")"
+ grandparent="$(basename "$(dirname "$(dirname "$model_path")")")"
+ if [[ "$name" == "huggingface" && "$parent" == "actor" && "$grandparent" == global_step_* ]]; then
+ basename "$(dirname "$(dirname "$(dirname "$model_path")")")"
+ return
+ fi
+ if [[ "$name" == checkpoint-* ]]; then
+ basename "$(dirname "$(dirname "$model_path")")"
+ else
+ echo "$name"
+ fi
+}
+
+checkpoint_tag() {
+ local model_path="${1%/}"
+ local name
+ name="$(basename "$model_path")"
+ local parent
+ local grandparent
+ parent="$(basename "$(dirname "$model_path")")"
+ grandparent="$(basename "$(dirname "$(dirname "$model_path")")")"
+ if [[ "$name" == "huggingface" && "$parent" == "actor" && "$grandparent" == global_step_* ]]; then
+ basename "$(dirname "$(dirname "$model_path")")"
+ return
+ fi
+ if [[ "$name" == checkpoint-* ]]; then
+ echo "$name"
+ else
+ echo "base"
+ fi
+}
+
+IFS=',' read -ra TASK_LIST <<< "$TASKS"
+for RAW_TASK in "${TASK_LIST[@]}"; do
+ TASK="$(echo "$RAW_TASK" | xargs)"
+ case "$TASK" in
+ all)
+ VLLM_TASKS="videomme,videommev2,videommmu,mmvu,mvbench,videoholmes,longvideobench,lvbench,mlvu,vsi,mindcube,spatial_grounding,tracking,stvg"
+ if [[ "${MMSI_BACKEND}" == "transformers" ]]; then
+ RUN_MMSI_TRANSFORMERS=1
+ else
+ append_vllm_task "mmsi"
+ fi
+ RUN_REVSI=1
+ ;;
+ videomme|videommev2|videommmu|mmvu|mvbench|videoholmes|longvideobench|lvbench|mlvu|vsi|mindcube|spatial_grounding|tracking|stvg)
+ append_vllm_task "$TASK"
+ ;;
+ mmsi)
+ if [[ "${MMSI_BACKEND}" == "transformers" ]]; then
+ RUN_MMSI_TRANSFORMERS=1
+ else
+ append_vllm_task "mmsi"
+ fi
+ ;;
+ revsi)
+ RUN_REVSI=1
+ ;;
+ spatial|grounding|refcoco|spatial_grounding_eval)
+ append_vllm_task "spatial_grounding"
+ ;;
+ spatial_temporal_grounding)
+ append_vllm_task "stvg"
+ ;;
+ temporal_grounding|timelens|timelens_bench)
+ RUN_TIMELENS=1
+ ;;
+ segmentation|seg|reasonseg|refseg)
+ RUN_SEGMENTATION=1
+ ;;
+ "")
+ ;;
+ *)
+ echo "ERROR: unknown TASK '${TASK}'. Supported: videomme,videommev2,videommmu,mmvu,mvbench,videoholmes,longvideobench,lvbench,mlvu,vsi,mmsi,mindcube,revsi,spatial_grounding,tracking,stvg,temporal_grounding,segmentation,all" >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -n "$VLLM_TASKS" ]]; then
+ TASKS="$VLLM_TASKS" python eval/task/eval_vllm.py "$@"
+fi
+
+if [[ "$RUN_MMSI_TRANSFORMERS" = "1" ]]; then
+ MMSI_FAMILY="$(model_family_tag "$MODEL_PATH")"
+ MMSI_CKPT="$(checkpoint_tag "$MODEL_PATH")"
+ MMSI_RUN_ROOT="${PROJECT_DIR}/outputs/${MMSI_FAMILY}/spatial_intelligence/mmsi/${MMSI_CKPT}/${MMSI_SETTING}/$(date +%Y%m%d_%H%M%S)"
+ CUDA_VISIBLE_DEVICES="$GPUS" \
+ PROCESSOR_PATH="$MODEL_PATH" \
+ DATA_FILE="$MMSI_DATA_FILE" \
+ OUTPUT_DIR="$MMSI_RUN_ROOT" \
+ IMAGE_MIN_PIXELS="$MMSI_IMAGE_MIN_PIXELS" \
+ IMAGE_MAX_PIXELS="$MMSI_IMAGE_MAX_PIXELS" \
+ MAX_IMAGES="$MMSI_MAX_IMAGES" \
+ MAX_NEW_TOKENS="$MMSI_MAX_NEW_TOKENS" \
+ MAX_SAMPLES="$MMSI_MAX_SAMPLES" \
+ CATEGORY="$MMSI_CATEGORY" \
+ ENABLE_THINKING="$MMSI_ENABLE_THINKING" \
+ ATTN_IMPLEMENTATION="$MMSI_ATTN_IMPLEMENTATION" \
+ EXPECTED_SAMPLES="$MMSI_EXPECTED_SAMPLES" \
+ bash eval/task/mmsi/run_eval_transformers.sh "$MODEL_PATH"
+fi
+
+if [[ "$RUN_REVSI" = "1" ]]; then
+ REVSI_FAMILY="$(model_family_tag "$MODEL_PATH")"
+ REVSI_CKPT="$(checkpoint_tag "$MODEL_PATH")"
+ REVSI_RUN_EXPECTED_SAMPLES="$REVSI_EXPECTED_SAMPLES"
+ if [[ "$REVSI_MAX_SAMPLES" != "0" || -n "$REVSI_TASK_FILTER" ]]; then
+ REVSI_RUN_EXPECTED_SAMPLES=0
+ fi
+ REVSI_RUN_ROOT="${PROJECT_DIR}/outputs/${REVSI_FAMILY}/spatial_intelligence/revsi/${REVSI_CKPT}/${REVSI_SETTING}/$(date +%Y%m%d_%H%M%S)"
+ CUDA_VISIBLE_DEVICES="$GPUS" \
+ REVSI_ROOT="$REVSI_VIDEO_ROOT" \
+ QA_FILE="$REVSI_DATA_FILE" \
+ VIDEO_ROOT="$REVSI_VIDEO_ROOT" \
+ FRAME_BUDGET="$REVSI_FRAME_BUDGET" \
+ OUTPUT_DIR="$REVSI_RUN_ROOT" \
+ MAX_FRAMES="$REVSI_MAX_FRAMES" \
+ EXACT_NFRAMES="$REVSI_EXACT_NFRAMES" \
+ FPS="$REVSI_FPS" \
+ VIDEO_MIN_PIXELS="$REVSI_VIDEO_MIN_PIXELS" \
+ VIDEO_MAX_PIXELS="$REVSI_VIDEO_MAX_PIXELS" \
+ VIDEO_TOTAL_PIXELS="$REVSI_VIDEO_TOTAL_PIXELS" \
+ BATCH_SIZE="$REVSI_BATCH_SIZE" \
+ MAX_MODEL_LEN="$REVSI_MAX_MODEL_LEN" \
+ MAX_NEW_TOKENS="$REVSI_MAX_NEW_TOKENS" \
+ GPU_MEM_UTIL="$REVSI_GPU_MEMORY_UTILIZATION" \
+ MAX_SAMPLES="$REVSI_MAX_SAMPLES" \
+ EXPECTED_SAMPLES="$REVSI_RUN_EXPECTED_SAMPLES" \
+ TASK_FILTER="$REVSI_TASK_FILTER" \
+ ENABLE_THINKING="$REVSI_ENABLE_THINKING" \
+ TP_SIZE="$TP_SIZE" \
+ bash eval/task/revsi/run_eval_vllm.sh "$MODEL_PATH"
+fi
+
+if [[ "$RUN_TIMELENS" = "1" ]]; then
+ TIMELENS_FAMILY="$(model_family_tag "$MODEL_PATH")"
+ TIMELENS_CKPT="$(checkpoint_tag "$MODEL_PATH")"
+ TIMELENS_SETTING="timelens-fps${TIMELENS_FPS}-min${TIMELENS_MIN_TOKENS}-total${TIMELENS_TOTAL_TOKENS}-new${TIMELENS_MAX_NEW_TOKENS}-${TIMELENS_DATASETS//,/_}"
+ TIMELENS_RUN_ROOT="${PROJECT_DIR}/outputs/${TIMELENS_FAMILY}/temporal_grounding/${TIMELENS_CKPT}/${TIMELENS_SETTING}/$(date +%Y%m%d_%H%M%S)"
+ CUDA_VISIBLE_DEVICES="$GPUS" \
+ DATASETS="$TIMELENS_DATASETS" \
+ SETTING="$TIMELENS_SETTING" \
+ OUTPUT_ROOT="$TIMELENS_RUN_ROOT" \
+ TIMELENS_USE_OUTPUT_ROOT_DIRECT=1 \
+ MIN_TOKENS="$TIMELENS_MIN_TOKENS" \
+ TOTAL_TOKENS="$TIMELENS_TOTAL_TOKENS" \
+ MAX_PIXELS="$TIMELENS_MAX_PIXELS" \
+ MAX_FRAMES="$TIMELENS_MAX_FRAMES" \
+ FPS="$TIMELENS_FPS" \
+ MAX_NEW_TOKENS="$TIMELENS_MAX_NEW_TOKENS" \
+ REPETITION_PENALTY="$TIMELENS_REPETITION_PENALTY" \
+ STOP_AFTER_ANSWER="$TIMELENS_STOP_AFTER_ANSWER" \
+ PROMPT_MODE="$TIMELENS_PROMPT_MODE" \
+ NUM_WORKERS="$TIMELENS_NUM_WORKERS" \
+ bash eval/task/temporal_grounding/run_eval.sh \
+ "$MODEL_PATH" \
+ "$TIMELENS_ENABLE_THINKING" \
+ "$TIMELENS_RUN_ROOT"
+fi
+
+if [[ "$RUN_SEGMENTATION" = "1" ]]; then
+ SEG_FAMILY="$(model_family_tag "$MODEL_PATH")"
+ SEG_CKPT="$(checkpoint_tag "$MODEL_PATH")"
+ SEG_RUN_ROOT="${PROJECT_DIR}/outputs/${SEG_FAMILY}/segmentation/${SEG_CKPT}/${SEGMENTATION_SETTING}/$(date +%Y%m%d_%H%M%S)"
+ CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-$GPUS}" \
+ PROCESSOR_PATH="$SEGMENTATION_PROCESSOR_PATH" \
+ BENCH_DIR="$SEGMENTATION_BENCH_DIR" \
+ DATA_ROOT="$SEGMENTATION_DATA_ROOT" \
+ DATASETS="$SEGMENTATION_DATASETS" \
+ DATA_TYPE="$SEGMENTATION_DATA_TYPE" \
+ PROMPT_MODE="$SEGMENTATION_PROMPT_MODE" \
+ ENABLE_THINKING="$SEGMENTATION_ENABLE_THINKING" \
+ BATCH_SIZE="$SEGMENTATION_BATCH_SIZE" \
+ MAX_NEW_TOKENS="$SEGMENTATION_MAX_NEW_TOKENS" \
+ MAX_MODEL_LEN="$SEGMENTATION_MAX_MODEL_LEN" \
+ MAX_PIXELS_IMAGE="$SEGMENTATION_MAX_PIXELS_IMAGE" \
+ MIN_PIXELS_IMAGE="$SEGMENTATION_MIN_PIXELS_IMAGE" \
+ VIDEO_MAX_PIXELS="$SEGMENTATION_VIDEO_MAX_PIXELS" \
+ VIDEO_MIN_PIXELS="$SEGMENTATION_VIDEO_MIN_PIXELS" \
+ VIDEO_TOTAL_PIXELS="$SEGMENTATION_VIDEO_TOTAL_PIXELS" \
+ MAX_FRAMES="$SEGMENTATION_MAX_FRAMES" \
+ FPS="$SEGMENTATION_FPS" \
+ VIDEO_READER="$SEGMENTATION_VIDEO_READER" \
+ TP_SIZE="$TP_SIZE" \
+ GPU_MEM_UTIL="$SEGMENTATION_GPU_MEM_UTIL" \
+ SEED="$SEGMENTATION_SEED" \
+ MAX_SAMPLES="$SEGMENTATION_MAX_SAMPLES" \
+ RUN_SAM2="$SEGMENTATION_RUN_SAM2" \
+ SAM2_CKPT="$SEGMENTATION_SAM2_CKPT" \
+ SAM2_CFG="$SEGMENTATION_SAM2_CFG" \
+ ONETHINKER_SEG_POST="$SEGMENTATION_POSTPROCESSOR_PATH" \
+ SAM2_NUM_GPUS="$SEGMENTATION_SAM2_NUM_GPUS" \
+ SAM2_WORKERS_PER_GPU="$SEGMENTATION_SAM2_WORKERS_PER_GPU" \
+ bash eval/task/segmentation/run_eval_vllm.sh \
+ "$MODEL_PATH" \
+ "$SEG_RUN_ROOT"
+fi
diff --git a/code/eval/task/eval_image_mc_vllm.py b/code/eval/task/eval_image_mc_vllm.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a8e1c78479d994fcd77817f75fde25b599e09a7
--- /dev/null
+++ b/code/eval/task/eval_image_mc_vllm.py
@@ -0,0 +1,511 @@
+#!/usr/bin/env python3
+"""Unified vLLM evaluator for image-sequence multiple-choice benchmarks.
+
+Supported benchmarks:
+ - MMSI-Bench: TSV/parquet with base64 image list in `image`.
+ - MindCube-Tiny: official 1,050-sample Hugging Face split.
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import base64
+import csv
+import io
+import json
+import os
+import re
+import sys
+import time
+from collections import defaultdict
+from typing import Any, Dict, List
+
+from canonical_data import load_json_records
+from mindcube.data_utils import (
+ OFFICIAL_DATA_SOURCE,
+ decode_mindcube_images,
+ load_mindcube_records,
+ mindcube_answer_value,
+ mindcube_group,
+ mindcube_prompt,
+)
+from PIL import Image
+
+CHOICES = list("ABCDEFGH")
+ANSWER_RE = re.compile(r"\s*(.*?)\s* ", re.DOTALL | re.IGNORECASE)
+TAG_RE = re.compile(r"?think>", re.IGNORECASE)
+FORMAT_PRIORITY = {
+ "start": 10,
+ "end": 9,
+ "phrase": 7,
+ "parentheses": 6,
+ "period": 5,
+ "colon": 4,
+ "right_paren": 3,
+ "space": 2,
+ "fallback": 0,
+}
+ANSWER_PHRASES = [
+ "the answer is",
+ "answer is",
+ "the correct answer is",
+ "correct answer is",
+ "the best answer is",
+ "best answer is",
+ "the correct option is",
+ "correct option is",
+ "i choose",
+ "i select",
+ "my answer is",
+ "答案是",
+ "答案为",
+]
+PROMPT_TAIL = (
+ "Choose the best answer from the options. "
+ "Put exactly one uppercase option letter inside ... "
+ "Do not explain. Example: A "
+)
+
+
+def strip_think_block(text: str) -> str:
+ text = (text or "").strip()
+ if not text:
+ return ""
+ parts = re.split(r"", text, flags=re.IGNORECASE)
+ if len(parts) > 1:
+ text = parts[-1]
+ else:
+ text = re.sub(r".*? ", "", text, flags=re.DOTALL | re.IGNORECASE)
+ return TAG_RE.sub("", text).strip()
+
+
+def strip_answer_tags(text: str) -> str:
+ matches = ANSWER_RE.findall(text or "")
+ if matches:
+ return matches[-1].strip()
+ return (text or "").strip()
+
+
+def extract_mcq_answer(response: str, choices: List[str] | None = None) -> str:
+ if not response or not response.strip():
+ return ""
+ all_choices = choices or CHOICES
+ text = strip_answer_tags(strip_think_block(response)).strip()
+ if not text:
+ return ""
+ for char in [",", ".", "!", "?", ";", ":", "'", '"', "。", ":"]:
+ text = text.strip(char)
+ padded = " " + text + " "
+ candidates = []
+
+ for ch in all_choices:
+ for token, fmt in (
+ (f"({ch})", "parentheses"),
+ (f"{ch}.", "period"),
+ (f"{ch}:", "colon"),
+ (f"{ch})", "right_paren"),
+ (f"{ch} ", "space"),
+ ):
+ pos = padded.rfind(token)
+ if pos != -1:
+ candidates.append((ch, pos, fmt))
+
+ lower = padded.lower()
+ for phrase in ANSWER_PHRASES:
+ idx = lower.rfind(phrase.lower())
+ if idx != -1:
+ after = idx + len(phrase)
+ for ch in all_choices:
+ m = re.search(rf"\b{re.escape(ch)}\b", padded[after:], flags=re.IGNORECASE)
+ if m:
+ candidates.append((ch, after + m.start(), "phrase"))
+
+ stripped = padded.strip()
+ for ch in all_choices:
+ if stripped.upper() == ch:
+ candidates.append((ch, 0, "start"))
+ elif stripped.startswith(ch) and (len(stripped) == 1 or not stripped[1].isalpha()):
+ candidates.append((ch, 0, "start"))
+ elif stripped.endswith(ch) and (len(stripped) == 1 or not stripped[-2].isalpha()):
+ candidates.append((ch, len(padded) - 1, "end"))
+
+ if not candidates:
+ for ch in all_choices:
+ m = re.search(rf"\b{re.escape(ch)}\b", padded)
+ if m:
+ candidates.append((ch, m.start(), "fallback"))
+ if not candidates:
+ return ""
+ candidates.sort(key=lambda x: (FORMAT_PRIORITY.get(x[2], 0), x[1]), reverse=True)
+ return candidates[0][0]
+
+
+def load_records(args) -> List[Dict[str, Any]]:
+ if args.bench == "mindcube":
+ return load_mindcube_records(
+ args.data_file,
+ chunk=args.chunk,
+ index=args.index,
+ expected_samples=args.expected_samples,
+ cache_dir=args.hf_cache_dir or None,
+ )
+ if args.data_file.endswith((".jsonl", ".json")):
+ return load_json_records(args.data_file)
+ if args.data_file.endswith(".parquet"):
+ import pandas as pd
+
+ return pd.read_parquet(args.data_file).to_dict("records")
+
+ csv.field_size_limit(sys.maxsize)
+ with open(args.data_file, encoding="utf-8", errors="replace", newline="") as f:
+ return list(csv.DictReader(f, delimiter="\t"))
+
+
+def shard(records: List[Dict[str, Any]], chunk: int, index: int) -> List[Dict[str, Any]]:
+ if chunk <= 1:
+ return records
+ return records[index::chunk]
+
+
+def decode_mmsi_images(value: Any, max_images: int) -> List[Image.Image]:
+ images_b64 = ast.literal_eval(value) if isinstance(value, str) else value
+ if not isinstance(images_b64, (list, tuple)):
+ images_b64 = [images_b64]
+ images = []
+ for item in images_b64[:max_images]:
+ if not item:
+ continue
+ path = ""
+ if isinstance(item, dict):
+ path = str(
+ item.get("path")
+ or item.get("image")
+ or item.get("image_path")
+ or ""
+ )
+ elif isinstance(item, str) and os.path.isfile(item):
+ path = item
+ if path:
+ images.append(Image.open(path).convert("RGB"))
+ continue
+ raw = item if isinstance(item, bytes) else base64.b64decode(str(item))
+ images.append(Image.open(io.BytesIO(raw)).convert("RGB"))
+ return images
+
+
+def normalize_choices(value: Any) -> List[str]:
+ if value is None:
+ return []
+ if hasattr(value, "tolist"):
+ value = value.tolist()
+ if isinstance(value, (list, tuple)):
+ return [str(v) for v in value]
+ return [str(value)]
+
+
+def answer_choices(choices: List[Any] | None) -> List[str]:
+ n = len(choices) if choices is not None else 0
+ return CHOICES[:n] if n > 0 else list("ABCD")
+
+
+def choices_from_question(question: str) -> List[str]:
+ found = re.findall(r"(?:^|[\s,])([A-H])\s*[:\).]", question or "")
+ out = []
+ for ch in found:
+ if ch not in out:
+ out.append(ch)
+ return out or list("ABCD")
+
+
+def build_prompt(args, rec: Dict[str, Any]) -> tuple[str, List[str]]:
+ if args.bench == "mmsi":
+ question = str(rec.get("question") or "").strip()
+ labels = choices_from_question(question)
+ return f"{question}\n{PROMPT_TAIL}", labels
+
+ return mindcube_prompt(rec, PROMPT_TAIL)
+
+
+def get_answer(args, rec: Dict[str, Any]) -> str:
+ answer = (
+ mindcube_answer_value(rec)
+ if args.bench == "mindcube"
+ else rec.get("answer")
+ )
+ if answer is None:
+ return ""
+ if args.bench == "mindcube" and isinstance(answer, (int, float)) and not isinstance(answer, bool):
+ idx = int(answer)
+ labels = answer_choices(normalize_choices(rec.get("choices")))
+ if 0 <= idx < len(labels):
+ return labels[idx]
+ return extract_mcq_answer(str(answer), CHOICES) or str(answer).strip().upper()[:1]
+
+
+def get_group_key(args, rec: Dict[str, Any]) -> str:
+ if args.bench == "mmsi":
+ return str(rec.get("category") or "unknown")
+ return mindcube_group(rec)
+
+
+def get_filter_key(args) -> str:
+ return "category" if args.bench == "mmsi" else "task"
+
+
+def render_chat_prompt(messages, processor, enable_thinking: bool = False) -> str:
+ return processor.apply_chat_template(
+ messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=enable_thinking,
+ )
+
+
+def prepare_for_vllm(images: List[Image.Image], prompt: str, processor, args):
+ from qwen_vl_utils import process_vision_info
+
+ content: List[Dict[str, Any]] = []
+ for image in images:
+ item: Dict[str, Any] = {
+ "type": "image",
+ "image": image,
+ "max_pixels": args.image_max_pixels,
+ }
+ if args.image_min_pixels > 0:
+ item["min_pixels"] = args.image_min_pixels
+ content.append(item)
+ content.append({"type": "text", "text": prompt})
+ messages = [{"role": "user", "content": content}]
+ text = render_chat_prompt(messages, processor, args.enable_thinking)
+ image_inputs, _video_inputs = process_vision_info(messages, image_patch_size=args.patch_size)
+ return {
+ "prompt": text,
+ "multi_modal_data": {"image": image_inputs},
+ "mm_processor_kwargs": {"do_resize": False},
+ }
+
+
+def pct(correct: int, total: int) -> float:
+ return round(100.0 * correct / total, 2) if total else 0.0
+
+
+def aggregate(args, results: List[Dict[str, Any]]) -> Dict[str, Any]:
+ correct = sum(int(r.get("score", 0)) for r in results)
+ parsed = sum(1 for r in results if r.get("pred_answer"))
+ groups = defaultdict(lambda: {"correct": 0, "total": 0})
+ for r in results:
+ groups[str(r.get("group") or "unknown")]["total"] += 1
+ groups[str(r.get("group") or "unknown")]["correct"] += int(r.get("score", 0))
+ group_name = "by_category" if args.bench == "mmsi" else "by_task"
+ return {
+ "num_samples": len(results),
+ "correct": correct,
+ "accuracy": pct(correct, len(results)),
+ "parse_rate": pct(parsed, len(results)),
+ group_name: {
+ k: {"accuracy": pct(v["correct"], v["total"]), "correct": v["correct"], "total": v["total"]}
+ for k, v in sorted(groups.items())
+ },
+ }
+
+
+def decode_images_for_record(args, rec: Dict[str, Any]) -> List[Image.Image]:
+ if args.bench == "mmsi":
+ return decode_mmsi_images(
+ rec.get("images") or rec.get("image"),
+ args.max_images,
+ )
+ return decode_mindcube_images(rec, args.max_images, args.min_image_bytes)
+
+
+def evaluate(llm, sampling_params, processor, args) -> Dict[str, Any]:
+ records = load_records(args)
+ filter_value = args.category if args.bench == "mmsi" else args.task
+ if filter_value:
+ if args.bench == "mindcube":
+ records = [
+ record
+ for record in records
+ if mindcube_group(record) == filter_value
+ ]
+ else:
+ key = get_filter_key(args)
+ records = [
+ record
+ for record in records
+ if str(record.get(key) or "") == filter_value
+ ]
+ if args.max_samples and args.max_samples > 0:
+ records = records[: args.max_samples]
+ if args.bench == "mmsi":
+ records = shard(records, args.chunk, args.index)
+ print(f"Loaded {len(records)} {args.bench} samples for shard {args.index}/{args.chunk}", flush=True)
+
+ results: List[Dict[str, Any]] = []
+ t0 = time.time()
+ for start in range(0, len(records), args.batch_size):
+ batch = records[start:start + args.batch_size]
+ inputs = []
+ keep = []
+ prompts: Dict[int, str] = {}
+ labels_by_j: Dict[int, List[str]] = {}
+ for j, rec in enumerate(batch):
+ try:
+ images = decode_images_for_record(args, rec)
+ if not images:
+ raise ValueError("no decoded images")
+ prompt, labels = build_prompt(args, rec)
+ inputs.append(prepare_for_vllm(images, prompt, processor, args))
+ keep.append(j)
+ prompts[j] = prompt
+ labels_by_j[j] = labels
+ except Exception as e:
+ print(f"[warn] skip sample {start + j}: {type(e).__name__}: {e}", flush=True)
+
+ outputs = llm.generate(inputs, sampling_params=sampling_params) if inputs else []
+ out_by_j = {j: out for j, out in zip(keep, outputs)}
+
+ for j, rec in enumerate(batch):
+ if j not in out_by_j:
+ continue
+ raw = out_by_j[j].outputs[0].text
+ labels = labels_by_j[j]
+ pred = extract_mcq_answer(raw, labels)
+ gt = get_answer(args, rec)
+ score = 1.0 if pred and gt and pred.upper() == gt.upper() else 0.0
+ results.append({
+ "id": str(rec.get("index") or rec.get("id") or rec.get("sample_id") or start + j),
+ "bench": args.bench,
+ "group": get_group_key(args, rec),
+ "question": rec.get("question"),
+ "prompt": prompts.get(j, ""),
+ "answer": gt,
+ "pred_answer": pred,
+ "raw_prediction": raw,
+ "score": score,
+ })
+
+ done = min(start + args.batch_size, len(records))
+ if done % 200 == 0 or done == len(records):
+ elapsed = max(time.time() - t0, 1e-6)
+ summary = aggregate(args, results)
+ print(
+ f"[{done}/{len(records)}] {elapsed:.1f}s "
+ f"acc={summary['accuracy']:.2f}% parse={summary['parse_rate']:.2f}%",
+ flush=True,
+ )
+
+ full_mindcube_eval = (
+ args.bench == "mindcube"
+ and args.chunk == 1
+ and not args.task
+ and not args.max_samples
+ )
+ if (
+ full_mindcube_eval
+ and args.expected_samples > 0
+ and len(results) != args.expected_samples
+ ):
+ raise RuntimeError(
+ f"Expected {args.expected_samples} official MindCube-Tiny "
+ f"results, got {len(results)}."
+ )
+
+ out_name = f"results_{args.bench}"
+ if args.chunk > 1:
+ out_name += f"_shard{args.index}"
+ out_path = os.path.join(args.output_dir, out_name + ".json")
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump(results, f, ensure_ascii=False, indent=2)
+
+ summary = aggregate(args, results)
+ if args.bench == "mindcube":
+ summary["data_source"] = args.data_file
+ if full_mindcube_eval and args.expected_samples > 0:
+ summary["expected_samples"] = args.expected_samples
+ summary["coverage"] = round(
+ 100.0 * len(results) / args.expected_samples, 2
+ )
+ summary_name = f"summary_shard{args.index}.json" if args.chunk > 1 else "summary.json"
+ with open(os.path.join(args.output_dir, summary_name), "w", encoding="utf-8") as f:
+ json.dump(summary, f, ensure_ascii=False, indent=2)
+ print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True)
+ return summary
+
+
+def parse_args():
+ p = argparse.ArgumentParser(description="Unified image-sequence MC evaluation via vLLM.")
+ p.add_argument("--bench", required=True, choices=["mmsi", "mindcube"])
+ p.add_argument("--model_path", required=True)
+ p.add_argument("--processor_path", default="")
+ p.add_argument("--data_file", default="")
+ p.add_argument("--output_dir", required=True)
+ p.add_argument("--image_min_pixels", type=int, default=4096)
+ p.add_argument("--image_max_pixels", type=int, default=262144)
+ p.add_argument("--max_images", type=int, default=8)
+ p.add_argument("--min_image_bytes", type=int, default=1024)
+ p.add_argument("--patch_size", type=int, default=16)
+ p.add_argument("--tensor_parallel_size", type=int, default=1)
+ p.add_argument("--gpu_memory_utilization", type=float, default=0.95)
+ p.add_argument("--max_model_len", type=int, default=65536)
+ p.add_argument("--max_new_tokens", type=int, default=64)
+ p.add_argument("--max_num_batched_tokens", type=int, default=65536)
+ p.add_argument("--batch_size", type=int, default=16)
+ p.add_argument("--max_samples", type=int, default=0)
+ p.add_argument("--expected_samples", type=int, default=1050)
+ p.add_argument("--hf_cache_dir", default="")
+ p.add_argument("--category", default="")
+ p.add_argument("--task", default="")
+ p.add_argument("--temperature", type=float, default=0.0)
+ p.add_argument("--top_p", type=float, default=1.0)
+ p.add_argument("--top_k", type=int, default=-1)
+ p.add_argument("--chunk", type=int, default=1)
+ p.add_argument("--index", type=int, default=0)
+ p.add_argument("--enable_thinking", action="store_true")
+ return p.parse_args()
+
+
+def main():
+ args = parse_args()
+ if args.bench == "mindcube" and not args.data_file:
+ args.data_file = OFFICIAL_DATA_SOURCE
+ if not args.data_file:
+ raise ValueError("--data_file is required for MMSI-Bench")
+ args.processor_path = args.processor_path or args.model_path
+ os.makedirs(args.output_dir, exist_ok=True)
+
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ processor = AutoProcessor.from_pretrained(
+ args.processor_path,
+ padding_side="left",
+ trust_remote_code=True,
+ min_pixels=args.image_min_pixels,
+ max_pixels=args.image_max_pixels,
+ )
+ tokenizer = AutoTokenizer.from_pretrained(args.processor_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tensor_parallel_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ limit_mm_per_prompt={"image": args.max_images},
+ )
+ sampling = SamplingParams(
+ max_tokens=args.max_new_tokens,
+ temperature=args.temperature,
+ top_p=args.top_p,
+ top_k=args.top_k,
+ )
+ evaluate(llm, sampling, processor, args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/eval_prompt.py b/code/eval/task/eval_prompt.py
new file mode 100644
index 0000000000000000000000000000000000000000..942d75f63b14729ccfc468d4ddf6767f41a85d44
--- /dev/null
+++ b/code/eval/task/eval_prompt.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+"""Single source of truth for evaluation prompts.
+
+Exactly ONE prompt per task — the one used by ``eval/task/eval.sh`` — kept
+byte-for-byte aligned with the SFT training data in
+``data/joint/sft_joint_all.jsonl``.
+
+Task -> prompt mapping (verbatim from sft_joint_all.jsonl):
+
+ video_qa_mc (videomme, videommmu, mmvu, mvbench, videoholmes,
+ longvideobench, lvbench, mlvu)
+ "{question}\\nOptions:\\n{opts}\\n" + VIDEO_QA_MC_TAIL
+
+ spatial_intelligence (vsi)
+ MC -> "{q}\\nOptions:\\n{opts}\\n" + VSI_MC_TAIL
+ integer -> "{q} " + VSI_INTEGER_TAIL
+ meters -> "{q} " + VSI_METERS_TAIL
+ cm -> "{q} " + VSI_CENTIMETERS_TAIL
+ room m2 -> "{q} " + VSI_SQUARE_METERS_TAIL
+ (each prefixed with "These are frames of a video.\\n")
+
+ sensenova_si / image_sequence_mc (mmsi, mindcube)
+ "{q}\\n" + SENSENOVA_MC_TAIL (alias PROMPT_TAIL)
+
+ spatial grounding (spatial_grounding)
+ build_spatial_grounding_prompt(expr) == QWEN_NATIVE_PROMPT_SG
+
+ tracking
+ GROUNDING_QUESTION_TEMPLATE_NO_THINK.format(Question=q) + TRACKING_TAIL
+
+ stvg
+ GROUNDING_QUESTION_TEMPLATE_NO_THINK.format(
+ Question=TRAIN_STVG_QUESTION_PREFIX.format(query=q)) + STVG_TAIL
+
+ segmentation
+ "{question}\\n" + TRAIN_SEG_IMAGE_TAIL / TRAIN_SEG_VIDEO_TAIL
+
+ temporal grounding (temporal_grounding)
+ TEMPORAL_GROUNDING_PROMPT.format(event)
+
+Keep this file dependency-free (stdlib only) so it can be imported from every
+worker regardless of the runtime environment.
+"""
+
+# ===========================================================================
+# Multiple-choice / numeric answer tails
+# ===========================================================================
+
+# video_qa_mc — videomme, videommmu, mmvu, mvbench, videoholmes,
+# longvideobench, lvbench, mlvu.
+VIDEO_QA_MC_TAIL = (
+ "Answer with the option letter only within ... tags. "
+ "Example: A "
+)
+
+# spatial_intelligence multiple choice — vsi (object_rel_distance,
+# object_rel_direction, obj_appearance_order, route_plan, ...).
+VSI_MC_TAIL = (
+ "Answer with the option letter within ... tags. "
+ "Example: A "
+)
+
+# spatial_intelligence numeric — vsi.
+VSI_INTEGER_TAIL = (
+ "Answer with an integer within ... tags. Example: 3 "
+)
+VSI_METERS_TAIL = (
+ "Answer with a number in meters within ... tags. Example: 2.3 "
+)
+VSI_CENTIMETERS_TAIL = (
+ "Answer with a number in centimeters within ... tags. Example: 120.5 "
+)
+VSI_SQUARE_METERS_TAIL = (
+ "Answer with a number in square meters within ... tags. Example: 25.5 "
+)
+
+# sensenova_si / image_sequence_mc_answer_only — mmsi, mindcube.
+SENSENOVA_MC_TAIL = (
+ "Choose the best answer from the options. "
+ "Put exactly one uppercase option letter inside ... "
+ "Do not explain. Example: A "
+)
+# Backwards-compatible alias used throughout eval_vllm.py.
+PROMPT_TAIL = SENSENOVA_MC_TAIL
+
+
+# ===========================================================================
+# Tracking
+# ===========================================================================
+
+# Question scaffold shared by tracking + stvg (the tail carries the full
+# answer-format spec).
+GROUNDING_QUESTION_TEMPLATE_NO_THINK = (
+ "{Question}\n"
+ "Please answer this question based on the visual content. "
+)
+
+TRACKING_TAIL = (
+ "Please track the target object throughout the video and provide one bounding box per second, "
+ "ONLY up to 32 seconds, within the ... tags.\n"
+ "Example:\n"
+ "{\"boxes\": {\"1\": [405, 230, 654, 463], \"2\": [435, 223, 678, 446], "
+ "\"32\": [415, 203, 691, 487]}} \n"
+ "Note: Each key in 'boxes' must correspond to a second (1, 2, 3, ..., 32) "
+ "and contain a 4-number bounding box [x1, y1, x2, y2]."
+)
+
+
+# ===========================================================================
+# Spatial-temporal grounding (STVG)
+# ===========================================================================
+
+STVG_TAIL = (
+ "Please provide only the time span in seconds and bounding boxes as JSON "
+ "within the ... tags.\n"
+ "You MUST output one bounding box for every integer second within the "
+ "given time span (inclusive).\n"
+ "Example:\n"
+ "{\"time\": [8.1, 13.5], \"boxes\": {\"9\": [317, 422, 582, 997], "
+ "\"10\": [332, 175, 442, 369], \"11\": [340, 180, 450, 370]}} \n"
+ "Note: Each key in 'boxes' must be an integer second within the span, "
+ "and its value must be a 4-number bounding box [x1, y1, x2, y2]."
+)
+
+# Rewrites an eval question into the exact wording used during STVG training.
+TRAIN_STVG_QUESTION_PREFIX = (
+ 'Given the query "{query}", when and where does the described content '
+ 'occur in the video? please firstly give the start and end time, spatial '
+ 'bounding box corresponding to each integer second.'
+)
+
+
+# ===========================================================================
+# Segmentation
+# ===========================================================================
+
+# Matches sft_joint_all.jsonl seg_image / seg_video samples.
+TRAIN_SEG_IMAGE_TAIL = (
+ "Please answer this question based on the visual content. "
+ "This task prepares inputs for image object segmentation with a specialized model (e.g., SAM2).\n"
+ "Please provide ONE bounding box, 3 positive points (clearly INSIDE the object), "
+ "and 3 negative points (clearly OUTSIDE the object) within the ... tags.\n"
+ "Choose informative points that help distinguish object vs. background. Prefer negatives on clear non-object "
+ "pixels INSIDE the box when safe; otherwise place them just outside on obvious background. "
+ "Negatives must NEVER be on the object or on its boundary.\n"
+ "Example: {\"boxes\": [x1, y1, x2, y2], \"positive_points\": [[x,y],[x,y],[x,y]], "
+ "\"negative_points\": [[x,y],[x,y],[x,y]]} "
+)
+
+TRAIN_SEG_VIDEO_TAIL = (
+ "Please answer this question based on the visual content. "
+ "This task prepares inputs for video object segmentation with a specialized model (e.g., SAM2).\n"
+ "Please select ONE representative time (in seconds), and provide ONE bounding box, "
+ "3 positive points (clearly INSIDE the object), and 3 negative points (clearly OUTSIDE the object) "
+ "within the ... tags.\n"
+ "Choose informative points that help distinguish object vs. background. Prefer negatives on clear non-object "
+ "pixels INSIDE the box when safe; otherwise place them just outside on obvious background. "
+ "Negatives must NEVER be on the object or on its boundary.\n"
+ "Example: {\"time\": , \"boxes\": [x1, y1, x2, y2], "
+ "\"positive_points\": [[x,y],[x,y],[x,y]], \"negative_points\": [[x,y],[x,y],[x,y]]} "
+)
+
+
+# ===========================================================================
+# Spatial grounding (RefCOCO / spatial_grounding)
+# ===========================================================================
+
+# Matches sft_joint_all.jsonl spatial grounding samples after the token.
+QWEN_NATIVE_PROMPT_SG = (
+ 'Locate "{}" in the image. Output its bounding box in JSON format '
+ 'within ... tags. '
+ 'Example: [{{"bbox_2d": [123, 30, 404, 846]}}] '
+)
+
+
+# ===========================================================================
+# Temporal grounding (TimeLens / temporal_grounding)
+# ===========================================================================
+
+# Matches sft_joint_all.jsonl temporal grounding samples (after ).
+TEMPORAL_GROUNDING_PROMPT = (
+ 'To accurately pinpoint the event "{}" in the video, '
+ "determine the precise time period of the event. "
+ "Provide the start and end times (in seconds) "
+ 'in the format "start time to end time" within tags. '
+ "Example: 12 to 18 "
+)
+# Exact prompt used by the official TimeLens evaluation.
+TIMELENS_OFFICIAL_PROMPT = (
+ "Please find the visual event described by the sentence '{}', "
+ "determining its starting and ending times. "
+ "The format should be: 'The event happens in - seconds'."
+)
+# Alias used by eval_timelens_hf.py.
+PROMPT_WO_THINK = TEMPORAL_GROUNDING_PROMPT
+TEMPORAL_GROUNDING_PROMPT_MODES = {
+ "same": TEMPORAL_GROUNDING_PROMPT,
+ "timelens_official": TIMELENS_OFFICIAL_PROMPT,
+}
+
+
+# ===========================================================================
+# Builder helpers
+# ===========================================================================
+
+def build_video_qa_mc_prompt(question, options_block):
+ """`{question}\\nOptions:\\n{options_block}\\n{VIDEO_QA_MC_TAIL}`."""
+ return f"{question}\nOptions:\n{options_block}\n{VIDEO_QA_MC_TAIL}"
+
+
+def build_sensenova_mc_prompt(question):
+ """`{question}\\n{SENSENOVA_MC_TAIL}`."""
+ return f"{question}\n{SENSENOVA_MC_TAIL}"
+
+
+def build_spatial_grounding_prompt(expression):
+ """Training-aligned spatial grounding prompt."""
+ expression = (expression or "").strip()
+ if expression and expression[-1] not in ".?!":
+ expression += "."
+ return QWEN_NATIVE_PROMPT_SG.format(expression)
+
+
+def build_tracking_prompt(question):
+ return GROUNDING_QUESTION_TEMPLATE_NO_THINK.format(Question=question) + TRACKING_TAIL
+
+
+def build_stvg_prompt(raw_query):
+ q = TRAIN_STVG_QUESTION_PREFIX.format(query=raw_query)
+ return GROUNDING_QUESTION_TEMPLATE_NO_THINK.format(Question=q) + STVG_TAIL
+
+
+def build_seg_prompt(question, data_type):
+ tail = TRAIN_SEG_VIDEO_TAIL if str(data_type).strip().lower() == "video" else TRAIN_SEG_IMAGE_TAIL
+ return f"{question}\n{tail}"
+
+
+def build_temporal_grounding_prompt(event, prompt_mode="same"):
+ try:
+ template = TEMPORAL_GROUNDING_PROMPT_MODES[prompt_mode]
+ except KeyError as exc:
+ choices = ", ".join(sorted(TEMPORAL_GROUNDING_PROMPT_MODES))
+ raise ValueError(
+ f"Unknown temporal grounding prompt mode {prompt_mode!r}; "
+ f"choose one of: {choices}"
+ ) from exc
+ return template.format(event)
diff --git a/code/eval/task/eval_revsi_multinode.sh b/code/eval/task/eval_revsi_multinode.sh
new file mode 100644
index 0000000000000000000000000000000000000000..bc6e73ba8e8025aaaf34c1a0350b6827474449e3
--- /dev/null
+++ b/code/eval/task/eval_revsi_multinode.sh
@@ -0,0 +1,229 @@
+#!/usr/bin/env bash
+# ReVSI evaluation using the same vLLM multi-node sharding path as VSI-Bench.
+set -euo pipefail
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+PROJECT_DIR=$(cd -- "${SCRIPT_DIR}/../.." && pwd)
+RUNNER="${SCRIPT_DIR}/revsi/run_eval_vllm.sh"
+MERGER="${SCRIPT_DIR}/revsi/merge_multinode_shards.py"
+PYTHON_BIN=${PYTHON_BIN:-$(command -v python)}
+HOSTFILE=${HOSTFILE:-}
+NUM_NODES=${NUM_NODES:-4}
+GPUS_PER_NODE=${GPUS_PER_NODE:-8}
+
+MODEL=${MODEL:-}
+MODEL=${MODEL%/}
+REVSI_ROOT=${REVSI_ROOT:-}
+FRAME_BUDGET=${REVSI_FRAME_BUDGET:-all}
+FRAME_DIR="${REVSI_ROOT:+${REVSI_ROOT}/${FRAME_BUDGET}_frame}"
+QA_FILE=${REVSI_QA_FILE:-"${FRAME_DIR:+${FRAME_DIR}/test-00000-of-00001.parquet}"}
+VIDEO_ROOT=${REVSI_VIDEO_ROOT:-${REVSI_ROOT}}
+TASK_FILTER=${REVSI_TASK_FILTER:-}
+MAX_SAMPLES=${REVSI_MAX_SAMPLES:-0}
+REMOTE_SETUP=${REMOTE_SETUP:-}
+SSH_STRICT_HOST_KEY_CHECKING=${SSH_STRICT_HOST_KEY_CHECKING:-yes}
+
+case "${FRAME_BUDGET}" in
+ 16|32|64|all) ;;
+ *)
+ echo "[FATAL] REVSI_FRAME_BUDGET must be 16, 32, 64, or all." >&2
+ exit 2
+ ;;
+esac
+[[ -n "${MODEL}" ]] || {
+ echo "[FATAL] Set MODEL to the model directory." >&2
+ exit 2
+}
+[[ -n "${QA_FILE}" ]] || {
+ echo "[FATAL] Set REVSI_QA_FILE or REVSI_ROOT." >&2
+ exit 2
+}
+[[ -n "${HOSTFILE}" ]] || {
+ echo "[FATAL] Set HOSTFILE to one host per line." >&2
+ exit 2
+}
+for path in \
+ "${RUNNER}" "${MERGER}" "${HOSTFILE}" "${MODEL}/config.json" "${QA_FILE}"; do
+ [[ -f "${path}" ]] || {
+ echo "[FATAL] Required input is unavailable: ${path}" >&2
+ exit 1
+ }
+done
+if [[ "${QA_FILE}" == *.parquet ]]; then
+ [[ -n "${FRAME_DIR}" ]] || {
+ echo "[FATAL] Parquet input requires REVSI_ROOT." >&2
+ exit 2
+ }
+ compgen -G "${FRAME_DIR}/*.mp4" >/dev/null || {
+ echo "[FATAL] No ReVSI videos found under ${FRAME_DIR}." >&2
+ exit 1
+ }
+fi
+
+if [[ -n "${REVSI_EXPECTED_SAMPLES:-}" ]]; then
+ EXPECTED_SAMPLES=${REVSI_EXPECTED_SAMPLES}
+elif [[ -z "${TASK_FILTER}" && "${MAX_SAMPLES}" == "0" ]]; then
+ EXPECTED_SAMPLES=6808
+else
+ EXPECTED_SAMPLES=0
+fi
+
+mapfile -t AVAILABLE_HOSTS < <(awk 'NF && !seen[$1]++ {print $1}' "${HOSTFILE}")
+[[ "${NUM_NODES}" =~ ^[1-9][0-9]*$ ]] || {
+ echo "[FATAL] NUM_NODES must be a positive integer." >&2
+ exit 2
+}
+((NUM_NODES <= ${#AVAILABLE_HOSTS[@]})) || {
+ echo "[FATAL] Requested ${NUM_NODES} nodes, only ${#AVAILABLE_HOSTS[@]} available." >&2
+ exit 2
+}
+HOSTS=("${AVAILABLE_HOSTS[@]:0:NUM_NODES}")
+GLOBAL_SHARDS=$((NUM_NODES * GPUS_PER_NODE))
+
+checkpoint_dir=$(dirname "$(dirname "${MODEL%/}")")
+checkpoint_tag=$(basename "${checkpoint_dir}")
+experiment_tag=$(basename "$(dirname "${checkpoint_dir}")")
+timestamp=$(date +%Y%m%d_%H%M%S)
+EVAL_MAX_FRAMES=${REVSI_MAX_FRAMES:-${FRAME_BUDGET}}
+EVAL_EXACT_NFRAMES=${REVSI_EXACT_NFRAMES:-1}
+if [[ "${FRAME_BUDGET}" == "all" ]]; then
+ EVAL_MAX_FRAMES=${REVSI_MAX_FRAMES:-128}
+fi
+setting_tag="revsi-${FRAME_BUDGET}frame-multinode${NUM_NODES}x${GPUS_PER_NODE}-f${EVAL_MAX_FRAMES}-exact${EVAL_EXACT_NFRAMES}-fps${REVSI_FPS:-2}-total${REVSI_VIDEO_TOTAL_PIXELS:-16777216}"
+OUTPUT_DIR=${OUTPUT_DIR:-"${PROJECT_DIR}/outputs/${experiment_tag}/spatial_intelligence/revsi/${checkpoint_tag}/${setting_tag}/${timestamp}"}
+LOG_DIR=${LOG_DIR:-"${PROJECT_DIR}/logs/revsi_multinode/${timestamp}"}
+mkdir -p "${OUTPUT_DIR}" "${LOG_DIR}"
+
+SSH_OPTS=(-o "StrictHostKeyChecking=${SSH_STRICT_HOST_KEY_CHECKING}" -o BatchMode=yes -o ServerAliveInterval=60)
+WORKER_PATTERN='eval/task/revsi/eval_revsi_vllm.py'
+
+run_on_host() {
+ local node=$1
+ shift
+ if ((node == 0)); then
+ bash -s <<<"$*"
+ else
+ ssh "${SSH_OPTS[@]}" "${HOSTS[node]}" bash -s <<<"$*"
+ fi
+}
+
+kill_stale_workers() {
+ local node reap_pids=() command
+ command="pkill -f $(printf %q "${WORKER_PATTERN}") >/dev/null 2>&1 || true; sleep 5; pkill -9 -f $(printf %q "${WORKER_PATTERN}") >/dev/null 2>&1 || true; sleep 3"
+ for ((node = 0; node < NUM_NODES; node++)); do
+ run_on_host "${node}" "${command}" >/dev/null 2>&1 &
+ reap_pids+=("$!")
+ done
+ wait "${reap_pids[@]}" 2>/dev/null || true
+}
+
+require_free_gpus() {
+ local node free bad=0
+ local need_gib=${REVSI_MIN_FREE_GIB:-75}
+ for ((node = 0; node < NUM_NODES; node++)); do
+ free=$(run_on_host "${node}" \
+ "nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits | sort -n | awk 'NR==1 {print}'" 2>/dev/null |
+ tr -d '\r' | awk '/^[0-9]+$/ {value=$0} END {print value}')
+ if [[ -z "${free}" ]]; then
+ echo "[FATAL] node=${node} host=${HOSTS[node]}: cannot query GPU memory." >&2
+ bad=1
+ elif ((free / 1024 < need_gib)); then
+ echo "[FATAL] node=${node} host=${HOSTS[node]}: only $((free / 1024)) GiB free (need ${need_gib} GiB)." >&2
+ bad=1
+ fi
+ done
+ ((bad == 0)) || exit 1
+}
+
+pids=()
+cleanup() {
+ local status=$?
+ trap - EXIT INT TERM
+ for pid in "${pids[@]:-}"; do
+ [[ -n "${pid}" ]] && kill "${pid}" >/dev/null 2>&1 || true
+ done
+ ((status == 0)) || kill_stale_workers
+ exit "${status}"
+}
+trap cleanup EXIT INT TERM
+
+kill_stale_workers
+require_free_gpus
+
+dispatch() {
+ local node=$1
+ local offset=$((node * GPUS_PER_NODE))
+ local gpu_ids
+ gpu_ids=$(seq -s, 0 $((GPUS_PER_NODE - 1)))
+ local env_values=(
+ "CUDA_VISIBLE_DEVICES=${gpu_ids}"
+ "REVSI_ROOT=${REVSI_ROOT}"
+ "FRAME_BUDGET=${FRAME_BUDGET}"
+ "QA_FILE=${QA_FILE}"
+ "VIDEO_ROOT=${VIDEO_ROOT}"
+ "OUTPUT_DIR=${OUTPUT_DIR}"
+ "GLOBAL_SHARD_COUNT=${GLOBAL_SHARDS}"
+ "GLOBAL_SHARD_OFFSET=${offset}"
+ "MERGE_SHARDS=0"
+ "EXPECTED_SAMPLES=0"
+ "TASK_FILTER=${TASK_FILTER}"
+ "MAX_SAMPLES=${MAX_SAMPLES}"
+ "MEDIA_MODE=video"
+ "TP_SIZE=1"
+ "BATCH_SIZE=${REVSI_BATCH_SIZE:-16}"
+ "MAX_FRAMES=${EVAL_MAX_FRAMES}"
+ "EXACT_NFRAMES=${EVAL_EXACT_NFRAMES}"
+ "FPS=${REVSI_FPS:-2}"
+ "VIDEO_MIN_PIXELS=${REVSI_VIDEO_MIN_PIXELS:-65536}"
+ "VIDEO_MAX_PIXELS=${REVSI_VIDEO_MAX_PIXELS:-}"
+ "VIDEO_TOTAL_PIXELS=${REVSI_VIDEO_TOTAL_PIXELS:-16777216}"
+ "MAX_MODEL_LEN=${REVSI_MAX_MODEL_LEN:-32768}"
+ "MAX_NEW_TOKENS=${REVSI_MAX_NEW_TOKENS:-64}"
+ "GPU_MEM_UTIL=${REVSI_GPU_MEMORY_UTILIZATION:-0.90}"
+ "STRICT_NUMERIC_PROMPT=${REVSI_STRICT_NUMERIC_PROMPT:-0}"
+ "ENABLE_THINKING=${REVSI_ENABLE_THINKING:-false}"
+ "SCORE_LOG_INTERVAL=${REVSI_SCORE_LOG_INTERVAL:-50}"
+ "LAUNCH_DELAY=2"
+ "VLLM_BASE_PORT=49000"
+ )
+ local prefix="" value
+ for value in "${env_values[@]}"; do
+ prefix+="$(printf %q "${value}") "
+ done
+ local setup_prefix=""
+ if [[ -n "${REMOTE_SETUP}" ]]; then
+ setup_prefix="${REMOTE_SETUP} && "
+ fi
+ local command="${setup_prefix}cd $(printf %q "${PROJECT_DIR}") && ${prefix}bash $(printf %q "${RUNNER}") $(printf %q "${MODEL}")"
+ local log="${LOG_DIR}/node${node}.log"
+ echo "[revsi] node=${node}/${NUM_NODES} host=${HOSTS[node]} shards=${offset}-$((offset + GPUS_PER_NODE - 1)) log=${log}"
+ if ((node == 0)); then
+ bash -lc "${command}" 2>&1 | tee "${log}"
+ else
+ ssh "${SSH_OPTS[@]}" "${HOSTS[node]}" \
+ "bash -lc $(printf %q "${command}")" >"${log}" 2>&1 &
+ pids+=("$!")
+ fi
+}
+
+for ((node = 1; node < NUM_NODES; node++)); do
+ dispatch "${node}"
+done
+dispatch 0
+
+status=0
+for pid in "${pids[@]}"; do
+ if ! wait "${pid}"; then status=1; fi
+done
+pids=()
+((status == 0)) || {
+ echo "[FATAL] One or more ReVSI nodes failed; inspect ${LOG_DIR}." >&2
+ exit "${status}"
+}
+
+"${PYTHON_BIN}" "${MERGER}" \
+ --output-dir "${OUTPUT_DIR}" \
+ --num-shards "${GLOBAL_SHARDS}" \
+ --expected-samples "${EXPECTED_SAMPLES}"
+chmod -R a+rX "${OUTPUT_DIR}" 2>/dev/null || true
+echo "[revsi] Multi-node evaluation complete: ${OUTPUT_DIR}/summary.json"
diff --git a/code/eval/task/eval_vllm.py b/code/eval/task/eval_vllm.py
new file mode 100644
index 0000000000000000000000000000000000000000..7482890a5b45b14d5d5c46e4ca93f67f49f4f3f2
--- /dev/null
+++ b/code/eval/task/eval_vllm.py
@@ -0,0 +1,3307 @@
+#!/usr/bin/env python3
+"""Single-file vLLM evaluator for VideoMME, VideoMME-V2, VideoMMMU, MVBench, VSI, MMSI, MindCube, grounding, Tracking, and STVG.
+
+This is the only evaluation entry point. It owns:
+ - task defaults
+ - output directory layout
+ - GPU sharding
+ - worker execution
+ - result merging
+
+Usage:
+ python eval/task/eval_vllm.py --tasks videomme,vsi --model_path /path/to/model
+ TASKS=mmsi bash eval/task/eval.sh
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import base64
+import csv
+import io
+import json
+import os
+import re
+import shlex
+import subprocess
+import sys
+import time
+from collections import OrderedDict, defaultdict
+from concurrent.futures import Future, ThreadPoolExecutor
+from pathlib import Path
+from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple
+
+import numpy as np
+from PIL import Image
+
+
+PROJECT_DIR = Path(__file__).resolve().parents[2]
+THIS_FILE = Path(__file__).resolve()
+THIS_DIR = Path(__file__).resolve().parent
+PYTHON = sys.executable
+if str(PROJECT_DIR) not in sys.path:
+ sys.path.insert(0, str(PROJECT_DIR))
+if str(THIS_DIR) not in sys.path:
+ sys.path.insert(0, str(THIS_DIR))
+
+from canonical_data import load_json_records # noqa: E402
+
+# Centralized prompts (single source of truth) — see eval/task/eval_prompt.py.
+from eval_prompt import ( # noqa: E402
+ PROMPT_TAIL,
+ VIDEO_QA_MC_TAIL,
+ VSI_CENTIMETERS_TAIL,
+ VSI_INTEGER_TAIL,
+ VSI_MC_TAIL,
+ VSI_METERS_TAIL,
+ VSI_SQUARE_METERS_TAIL,
+)
+from mindcube.data_utils import ( # noqa: E402
+ decode_mindcube_images,
+ load_mindcube_records,
+ mindcube_answer_value,
+ mindcube_group,
+ mindcube_prompt,
+)
+
+
+TASKS = ("videomme", "videommev2", "videommmu", "mmvu", "mvbench", "videoholmes", "longvideobench", "lvbench", "mlvu", "vsi", "mmsi", "mindcube", "spatial_grounding", "tracking", "stvg")
+OUTPUT_TASK_GROUPS = {
+ "videomme": "video_qa",
+ "videommev2": "video_qa",
+ "videommmu": "video_qa",
+ "mmvu": "video_qa",
+ "mvbench": "video_qa",
+ "videoholmes": "video_qa",
+ "longvideobench": "video_qa",
+ "lvbench": "video_qa",
+ "mlvu": "video_qa",
+ "vsi": "spatial_intelligence",
+ "mmsi": "spatial_intelligence",
+ "mindcube": "spatial_intelligence",
+ "stvg": "spatial_temporal_grounding",
+}
+CHOICES = list("ABCDEFGH")
+VIDEOMMMU_CHOICES = list("ABCDEFGHIJ")
+ANSWER_RE = re.compile(r"\s*(.*?)\s* ", re.DOTALL | re.IGNORECASE)
+TAG_RE = re.compile(r"?think>", re.IGNORECASE)
+LEGACY_MC_TAIL_RE = re.compile(
+ r"\s*(?:"
+ r"Answer with the option'?s letter from the given choices directly\.?|"
+ r"Answer with the option letter within \.\.\. tags\.?\s*(?:e\.g\.|Example:)?\s*A |"
+ r"Output only the option letter inside \.\.\. \.?\s*Do not explain\.?|"
+ r"Choose the best answer from the options\. Put exactly one uppercase option letter inside \.\.\. \s*Do not explain\. Example: A "
+ r")\s*$",
+ re.IGNORECASE,
+)
+ANSWER_PHRASES = [
+ "the answer is", "answer is", "the correct answer is", "correct answer is",
+ "the best answer is", "best answer is", "the correct option is",
+ "correct option is", "i choose", "i select", "my answer is", "答案是", "答案为",
+]
+FORMAT_PRIORITY = {
+ "start": 10, "end": 9, "phrase": 7, "parentheses": 6, "period": 5,
+ "colon": 4, "right_paren": 3, "space": 2, "fallback": 0,
+}
+
+
+# ---------------------------------------------------------------------------
+# Common parsing / scoring helpers
+# ---------------------------------------------------------------------------
+
+
+def read_json_or_jsonl(path: str) -> List[Dict[str, Any]]:
+ return load_json_records(path)
+
+
+def strip_think_block(text: str) -> str:
+ text = (text or "").strip()
+ if not text:
+ return ""
+ parts = re.split(r"", text, flags=re.IGNORECASE)
+ if len(parts) > 1:
+ text = parts[-1]
+ else:
+ text = re.sub(r".*? ", "", text, flags=re.DOTALL | re.IGNORECASE)
+ return TAG_RE.sub("", text).strip()
+
+
+def strip_answer_tags(text: str) -> str:
+ matches = ANSWER_RE.findall(text or "")
+ return matches[-1].strip() if matches else (text or "").strip()
+
+
+def extract_mcq_answer(response: str, choices: Optional[List[str]] = None) -> str:
+ if not response or not response.strip():
+ return ""
+ all_choices = choices or CHOICES
+ text = strip_answer_tags(strip_think_block(response)).strip()
+ if not text:
+ return ""
+ for char in [",", ".", "!", "?", ";", ":", "'", '"', "。", ":"]:
+ text = text.strip(char)
+ padded = " " + text + " "
+ candidates = []
+
+ for ch in all_choices:
+ for token, fmt in (
+ (f"({ch})", "parentheses"),
+ (f"{ch}.", "period"),
+ (f"{ch}:", "colon"),
+ (f"{ch})", "right_paren"),
+ (f"{ch} ", "space"),
+ ):
+ pos = padded.rfind(token)
+ if pos != -1:
+ candidates.append((ch, pos, fmt))
+
+ lower = padded.lower()
+ for phrase in ANSWER_PHRASES:
+ idx = lower.rfind(phrase.lower())
+ if idx != -1:
+ after = idx + len(phrase)
+ for ch in all_choices:
+ m = re.search(rf"\b{re.escape(ch)}\b", padded[after:], flags=re.IGNORECASE)
+ if m:
+ candidates.append((ch, after + m.start(), "phrase"))
+
+ stripped = padded.strip()
+ for ch in all_choices:
+ if stripped.upper() == ch:
+ candidates.append((ch, 0, "start"))
+ elif stripped.startswith(ch) and (len(stripped) == 1 or not stripped[1].isalpha()):
+ candidates.append((ch, 0, "start"))
+ elif stripped.endswith(ch) and (len(stripped) == 1 or not stripped[-2].isalpha()):
+ candidates.append((ch, len(padded) - 1, "end"))
+
+ if not candidates:
+ for ch in all_choices:
+ m = re.search(rf"\b{re.escape(ch)}\b", padded)
+ if m:
+ candidates.append((ch, m.start(), "fallback"))
+ if not candidates:
+ return ""
+ candidates.sort(key=lambda x: (FORMAT_PRIORITY.get(x[2], 0), x[1]), reverse=True)
+ return candidates[0][0]
+
+
+def _normalized_option_text(value: Any, label: str = "") -> str:
+ text = strip_answer_tags(strip_think_block(str(value or ""))).strip()
+ if label:
+ text = re.sub(
+ rf"^\s*{re.escape(label)}\s*[\.\)::]\s*",
+ "",
+ text,
+ count=1,
+ flags=re.IGNORECASE,
+ )
+ return " ".join(text.split()).casefold()
+
+
+def mcq_ground_truth(record: Dict[str, Any], labels: List[str]) -> str:
+ raw = next(
+ (
+ record[key]
+ for key in ("solution", "answer", "ground_truth")
+ if record.get(key) not in (None, "")
+ ),
+ "",
+ )
+ normalized_raw = _normalized_option_text(raw)
+ options = record.get("options") or record.get("choices") or []
+ if isinstance(options, list):
+ for index, option in enumerate(options):
+ if index >= len(labels):
+ break
+ label = str(labels[index])
+ if normalized_raw in {
+ _normalized_option_text(option),
+ _normalized_option_text(option, label),
+ }:
+ return label
+ return extract_mcq_answer(str(raw), labels)
+
+
+def require_mcq_ground_truth(
+ record: Dict[str, Any],
+ labels: List[str],
+ *,
+ task: str,
+) -> str:
+ answer = mcq_ground_truth(record, labels)
+ if answer:
+ return answer
+ identifier = record.get("problem_id") or record.get("question_id") or record.get("id")
+ raise ValueError(f"{task} sample {identifier!r} has no mappable ground-truth option")
+
+
+def pct(correct: int, total: int) -> float:
+ return round(100.0 * correct / total, 2) if total else 0.0
+
+
+def render_chat_prompt(messages: List[Dict[str, Any]], processor, enable_thinking: bool = False) -> str:
+ return processor.apply_chat_template(
+ messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=enable_thinking,
+ )
+
+
+def normalize_video_metadata(metadata: Any, frames: Any) -> Dict[str, Any]:
+ if isinstance(metadata, dict):
+ raw = dict(metadata)
+ elif hasattr(metadata, "keys") and hasattr(metadata, "__getitem__"):
+ raw = {k: metadata[k] for k in metadata.keys()}
+ else:
+ total = frames.shape[0] if hasattr(frames, "shape") else len(frames)
+ raw = {"fps": 2.0, "frames_indices": list(range(total)), "total_num_frames": total}
+ return {
+ k: raw[k]
+ for k in ("fps", "frames_indices", "total_num_frames", "video_backend")
+ if k in raw
+ }
+
+
+_VIDEO_CACHE: "OrderedDict[str, Tuple[Any, Dict[str, Any]]]" = OrderedDict()
+
+
+def load_preprocessed_video(path: str):
+ import torch
+
+ cache_size = int(os.environ.get("VIDEO_CACHE_SIZE", "1"))
+ if cache_size > 0 and path in _VIDEO_CACHE:
+ value = _VIDEO_CACHE.pop(path)
+ _VIDEO_CACHE[path] = value
+ return value
+
+ data = torch.load(path, map_location="cpu", weights_only=False)
+ frames = data["frames"]
+ value = (frames, normalize_video_metadata(data.get("metadata"), frames))
+ if cache_size > 0:
+ _VIDEO_CACHE[path] = value
+ while len(_VIDEO_CACHE) > cache_size:
+ _VIDEO_CACHE.popitem(last=False)
+ return value
+
+
+def prepare_preprocessed_video(messages: List[Dict[str, Any]], processor, path: str) -> Dict[str, Any]:
+ frames, metadata = load_preprocessed_video(path)
+ return {
+ "prompt": render_chat_prompt(messages, processor, False),
+ "multi_modal_data": {"video": [(frames, metadata)]},
+ "mm_processor_kwargs": {"do_sample_frames": False, "do_resize": False},
+ }
+
+
+def prepare_raw_video(messages: List[Dict[str, Any]], processor, patch_size: int) -> Dict[str, Any]:
+ from qwen_vl_utils import process_vision_info
+
+ text = render_chat_prompt(messages, processor, False)
+ _images, video_inputs, video_kwargs = process_vision_info(
+ messages,
+ image_patch_size=patch_size,
+ return_video_kwargs=True,
+ return_video_metadata=True,
+ )
+ video_kwargs = video_kwargs or {}
+ video_kwargs.setdefault("do_resize", False)
+ return {
+ "prompt": text,
+ "multi_modal_data": {"video": video_inputs},
+ "mm_processor_kwargs": video_kwargs,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Task defaults and output layout
+# ---------------------------------------------------------------------------
+
+
+def model_tags(model_path: str) -> Tuple[str, str]:
+ path = Path(model_path.rstrip("/"))
+ if path.name == "huggingface" and path.parent.name == "actor" and path.parent.parent.name.startswith("global_step_"):
+ return path.parent.parent.parent.name, path.parent.parent.name
+ if path.name.startswith("checkpoint-"):
+ return path.parent.parent.name, path.name
+ return path.name, "base"
+
+
+def make_output_dir(task: str, model_path: str, setting: str) -> Path:
+ family, ckpt = model_tags(model_path)
+ group = OUTPUT_TASK_GROUPS.get(task)
+ task_directory = Path(group) / task if group is not None else Path(task)
+ out = (
+ PROJECT_DIR
+ / "outputs"
+ / family
+ / task_directory
+ / ckpt
+ / setting
+ / time.strftime("%Y%m%d_%H%M%S")
+ )
+ out.mkdir(parents=True, exist_ok=True)
+ return out
+
+
+def json_safe(value: Any) -> Any:
+ if isinstance(value, Path):
+ return str(value)
+ if isinstance(value, dict):
+ return {str(k): json_safe(v) for k, v in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [json_safe(v) for v in value]
+ return value
+
+
+def split_gpus(gpus: str, tp_size: int) -> List[str]:
+ values = [x.strip() for x in gpus.split(",") if x.strip()]
+ if not values:
+ raise ValueError("empty GPU list")
+ duplicates = sorted({x for x in values if values.count(x) > 1})
+ if duplicates:
+ raise ValueError(f"Duplicate GPU ids in GPUS={gpus}: {duplicates}")
+ if len(values) % tp_size != 0:
+ raise ValueError(f"NUM_GPUS={len(values)} must be divisible by TP_SIZE={tp_size}")
+ return [",".join(values[i:i + tp_size]) for i in range(0, len(values), tp_size)]
+
+
+def shard_records(records: List[Dict[str, Any]], chunk: int, index: int) -> List[Dict[str, Any]]:
+ if chunk <= 1:
+ return records
+ mode = os.environ.get("SHARD_MODE", "contiguous").strip().lower()
+ if mode in {"round_robin", "stride", "strided"}:
+ return records[index::chunk]
+ n = len(records)
+ start = n * index // chunk
+ end = n * (index + 1) // chunk
+ return records[start:end]
+
+
+def iter_prefetched_batches(
+ records: List[Dict[str, Any]],
+ batch_size: int,
+ prepare_batch: Callable[[int, List[Dict[str, Any]]], Any],
+ prefetch_batches: int = 1,
+) -> Iterator[Any]:
+ """Prepare upcoming eval batches in a background thread while vLLM runs."""
+ starts = list(range(0, len(records), batch_size))
+ if not starts:
+ return
+ prefetch_batches = max(0, int(prefetch_batches or 0))
+ if prefetch_batches <= 0:
+ for start in starts:
+ yield prepare_batch(start, records[start:start + batch_size])
+ return
+
+ with ThreadPoolExecutor(max_workers=1) as ex:
+ pending: Dict[int, Future] = {}
+ submit_pos = 0
+
+ def submit_until_full() -> None:
+ nonlocal submit_pos
+ while submit_pos < len(starts) and len(pending) < prefetch_batches + 1:
+ start = starts[submit_pos]
+ pending[start] = ex.submit(prepare_batch, start, records[start:start + batch_size])
+ submit_pos += 1
+
+ submit_until_full()
+ for start in starts:
+ fut = pending.pop(start)
+ yield fut.result()
+ submit_until_full()
+
+
+def env_required(name: str) -> str:
+ value = os.environ.get(name, "").strip()
+ if not value:
+ raise ValueError(f"Missing required env var {name}; set it in eval/task/eval.sh or the shell.")
+ return value
+
+
+def env_value(name: str, default: Any) -> Any:
+ value = os.environ.get(name)
+ return default if value is None or value == "" else value
+
+
+def env_int(name: str, default: int) -> int:
+ return int(env_value(name, default))
+
+
+def env_float(name: str, default: float) -> float:
+ return float(env_value(name, default))
+
+
+def task_defaults(task: str) -> Dict[str, Any]:
+ if task == "videomme":
+ return {
+ "setting": env_required("VIDEOMME_SETTING"),
+ "data_file": env_required("VIDEOMME_DATA_FILE"),
+ "video_base": env_required("VIDEOMME_VIDEO_BASE"),
+ "video_dir": env_required("VIDEOMME_VIDEO_DIR"),
+ "preprocessed_video_dir": env_required("VIDEOMME_PREPROCESSED_VIDEO_DIR"),
+ "batch_size": env_int("VIDEOMME_BATCH_SIZE", 4),
+ "max_model_len": env_int("VIDEOMME_MAX_MODEL_LEN", 32768),
+ "max_new_tokens": env_int("VIDEOMME_MAX_NEW_TOKENS", 16),
+ "max_num_batched_tokens": env_int("VIDEOMME_MAX_NUM_BATCHED_TOKENS", 32768),
+ "gpu_memory_utilization": env_float("VIDEOMME_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("VIDEOMME_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("VIDEOMME_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("VIDEOMME_VIDEO_TOTAL_PIXELS", 0),
+ "max_frames": env_int("VIDEOMME_MAX_FRAMES", 384),
+ "fps": env_int("VIDEOMME_FPS", 2),
+ "max_samples": env_int("VIDEOMME_MAX_SAMPLES", 0),
+ }
+ if task == "videommev2":
+ return {
+ "setting": env_required("VIDEOMMEV2_SETTING"),
+ "data_file": env_required("VIDEOMMEV2_DATA_FILE"),
+ "video_base": env_required("VIDEOMMEV2_VIDEO_BASE"),
+ "video_dir": env_required("VIDEOMMEV2_VIDEO_DIR"),
+ "preprocessed_video_dir": env_required("VIDEOMMEV2_PREPROCESSED_VIDEO_DIR"),
+ "batch_size": env_int("VIDEOMMEV2_BATCH_SIZE", 1),
+ "max_model_len": env_int("VIDEOMMEV2_MAX_MODEL_LEN", 65536),
+ "max_new_tokens": env_int("VIDEOMMEV2_MAX_NEW_TOKENS", 128),
+ "max_num_batched_tokens": env_int("VIDEOMMEV2_MAX_NUM_BATCHED_TOKENS", 65536),
+ "gpu_memory_utilization": env_float("VIDEOMMEV2_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("VIDEOMMEV2_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("VIDEOMMEV2_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("VIDEOMMEV2_VIDEO_TOTAL_PIXELS", 0),
+ "max_frames": env_int("VIDEOMMEV2_MAX_FRAMES", 384),
+ "fps": env_int("VIDEOMMEV2_FPS", 2),
+ "prompt_mode": str(env_value("VIDEOMMEV2_PROMPT_MODE", "default")),
+ "answer_filter": str(env_value("VIDEOMMEV2_ANSWER_FILTER", "")),
+ "max_samples": env_int("VIDEOMMEV2_MAX_SAMPLES", 0),
+ }
+ if task == "videommmu":
+ return {
+ "setting": env_required("VIDEOMMMU_SETTING"),
+ "data_file": env_required("VIDEOMMMU_DATA_FILE"),
+ "video_root": env_required("VIDEOMMMU_VIDEO_ROOT"),
+ "batch_size": env_int("VIDEOMMMU_BATCH_SIZE", 4),
+ "max_model_len": env_int("VIDEOMMMU_MAX_MODEL_LEN", 65536),
+ "max_new_tokens": env_int("VIDEOMMMU_MAX_NEW_TOKENS", 64),
+ "max_num_batched_tokens": env_int("VIDEOMMMU_MAX_NUM_BATCHED_TOKENS", 65536),
+ "gpu_memory_utilization": env_float("VIDEOMMMU_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("VIDEOMMMU_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("VIDEOMMMU_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("VIDEOMMMU_VIDEO_TOTAL_PIXELS", 0),
+ "max_frames": env_int("VIDEOMMMU_MAX_FRAMES", 128),
+ "fps": env_int("VIDEOMMMU_FPS", 2),
+ "prompt_mode": str(env_value("VIDEOMMMU_PROMPT_MODE", "default")),
+ "image_max_pixels": env_int("VIDEOMMMU_IMAGE_MAX_PIXELS", 1048576),
+ "enable_thinking": env_value("VIDEOMMMU_ENABLE_THINKING", "false").lower() == "true",
+ "temperature": env_float("VIDEOMMMU_TEMPERATURE", 0.0),
+ "top_p": env_float("VIDEOMMMU_TOP_P", 1.0),
+ "top_k": env_int("VIDEOMMMU_TOP_K", -1),
+ "presence_penalty": env_float("VIDEOMMMU_PRESENCE_PENALTY", 0.0),
+ "min_p": env_float("VIDEOMMMU_MIN_P", 0.0),
+ }
+ if task == "mmvu":
+ return {
+ "setting": env_required("MMVU_SETTING"),
+ "data_file": env_required("MMVU_DATA_FILE"),
+ "video_root": env_value("MMVU_VIDEO_ROOT", ""),
+ "batch_size": env_int("MMVU_BATCH_SIZE", 4),
+ "max_model_len": env_int("MMVU_MAX_MODEL_LEN", 65536),
+ "max_new_tokens": env_int("MMVU_MAX_NEW_TOKENS", 64),
+ "max_num_batched_tokens": env_int("MMVU_MAX_NUM_BATCHED_TOKENS", 65536),
+ "gpu_memory_utilization": env_float("MMVU_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("MMVU_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("MMVU_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("MMVU_VIDEO_TOTAL_PIXELS", 0),
+ "max_frames": env_int("MMVU_MAX_FRAMES", 128),
+ "fps": env_int("MMVU_FPS", 2),
+ "prompt_mode": str(env_value("MMVU_PROMPT_MODE", "default")),
+ "enable_thinking": env_value("MMVU_ENABLE_THINKING", "false").lower() == "true",
+ "temperature": env_float("MMVU_TEMPERATURE", 0.0),
+ "top_p": env_float("MMVU_TOP_P", 1.0),
+ "top_k": env_int("MMVU_TOP_K", -1),
+ "presence_penalty": env_float("MMVU_PRESENCE_PENALTY", 0.0),
+ "min_p": env_float("MMVU_MIN_P", 0.0),
+ "max_samples": env_int("MMVU_MAX_SAMPLES", 0),
+ }
+ if task == "mvbench":
+ return {
+ "setting": env_required("MVBENCH_SETTING"),
+ "data_file": env_required("MVBENCH_DATA_FILE"),
+ "video_root": env_value("MVBENCH_VIDEO_ROOT", ""),
+ "batch_size": env_int("MVBENCH_BATCH_SIZE", 4),
+ "max_model_len": env_int("MVBENCH_MAX_MODEL_LEN", 65536),
+ "max_new_tokens": env_int("MVBENCH_MAX_NEW_TOKENS", 64),
+ "max_num_batched_tokens": env_int("MVBENCH_MAX_NUM_BATCHED_TOKENS", 65536),
+ "gpu_memory_utilization": env_float("MVBENCH_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("MVBENCH_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("MVBENCH_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("MVBENCH_VIDEO_TOTAL_PIXELS", 0),
+ "max_frames": env_int("MVBENCH_MAX_FRAMES", 128),
+ "fps": env_int("MVBENCH_FPS", 2),
+ "prompt_mode": str(env_value("MVBENCH_PROMPT_MODE", "default")),
+ "enable_thinking": env_value("MVBENCH_ENABLE_THINKING", "false").lower() == "true",
+ "temperature": env_float("MVBENCH_TEMPERATURE", 0.0),
+ "top_p": env_float("MVBENCH_TOP_P", 1.0),
+ "top_k": env_int("MVBENCH_TOP_K", -1),
+ "presence_penalty": env_float("MVBENCH_PRESENCE_PENALTY", 0.0),
+ "min_p": env_float("MVBENCH_MIN_P", 0.0),
+ "max_samples": env_int("MVBENCH_MAX_SAMPLES", 0),
+ }
+ if task == "videoholmes":
+ return {
+ "setting": env_required("VIDEOHOLMES_SETTING"),
+ "data_file": env_required("VIDEOHOLMES_DATA_FILE"),
+ "video_root": env_value("VIDEOHOLMES_VIDEO_ROOT", ""),
+ "batch_size": env_int("VIDEOHOLMES_BATCH_SIZE", 4),
+ "max_model_len": env_int("VIDEOHOLMES_MAX_MODEL_LEN", 65536),
+ "max_new_tokens": env_int("VIDEOHOLMES_MAX_NEW_TOKENS", 64),
+ "max_num_batched_tokens": env_int("VIDEOHOLMES_MAX_NUM_BATCHED_TOKENS", 65536),
+ "gpu_memory_utilization": env_float("VIDEOHOLMES_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("VIDEOHOLMES_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("VIDEOHOLMES_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("VIDEOHOLMES_VIDEO_TOTAL_PIXELS", 0),
+ "max_frames": env_int("VIDEOHOLMES_MAX_FRAMES", 128),
+ "fps": env_int("VIDEOHOLMES_FPS", 2),
+ "prompt_mode": str(env_value("VIDEOHOLMES_PROMPT_MODE", "default")),
+ "enable_thinking": env_value("VIDEOHOLMES_ENABLE_THINKING", "false").lower() == "true",
+ "temperature": env_float("VIDEOHOLMES_TEMPERATURE", 0.0),
+ "top_p": env_float("VIDEOHOLMES_TOP_P", 1.0),
+ "top_k": env_int("VIDEOHOLMES_TOP_K", -1),
+ "presence_penalty": env_float("VIDEOHOLMES_PRESENCE_PENALTY", 0.0),
+ "min_p": env_float("VIDEOHOLMES_MIN_P", 0.0),
+ "max_samples": env_int("VIDEOHOLMES_MAX_SAMPLES", 0),
+ }
+ if task == "longvideobench":
+ return {
+ "setting": env_required("LONGVIDEOBENCH_SETTING"),
+ "data_file": env_required("LONGVIDEOBENCH_DATA_FILE"),
+ "video_root": env_value("LONGVIDEOBENCH_VIDEO_ROOT", ""),
+ "subtitle_root": env_value("LONGVIDEOBENCH_SUBTITLE_ROOT", ""),
+ "use_subtitles": env_value("LONGVIDEOBENCH_USE_SUBTITLES", "true").lower() == "true",
+ "batch_size": env_int("LONGVIDEOBENCH_BATCH_SIZE", 4),
+ "max_model_len": env_int("LONGVIDEOBENCH_MAX_MODEL_LEN", 65536),
+ "max_new_tokens": env_int("LONGVIDEOBENCH_MAX_NEW_TOKENS", 64),
+ "max_num_batched_tokens": env_int("LONGVIDEOBENCH_MAX_NUM_BATCHED_TOKENS", 65536),
+ "gpu_memory_utilization": env_float("LONGVIDEOBENCH_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("LONGVIDEOBENCH_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("LONGVIDEOBENCH_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("LONGVIDEOBENCH_VIDEO_TOTAL_PIXELS", 0),
+ "max_frames": env_int("LONGVIDEOBENCH_MAX_FRAMES", 128),
+ "fps": env_int("LONGVIDEOBENCH_FPS", 2),
+ "prompt_mode": str(env_value("LONGVIDEOBENCH_PROMPT_MODE", "default")),
+ "enable_thinking": env_value("LONGVIDEOBENCH_ENABLE_THINKING", "false").lower() == "true",
+ "temperature": env_float("LONGVIDEOBENCH_TEMPERATURE", 0.0),
+ "top_p": env_float("LONGVIDEOBENCH_TOP_P", 1.0),
+ "top_k": env_int("LONGVIDEOBENCH_TOP_K", -1),
+ "presence_penalty": env_float("LONGVIDEOBENCH_PRESENCE_PENALTY", 0.0),
+ "min_p": env_float("LONGVIDEOBENCH_MIN_P", 0.0),
+ "max_samples": env_int("LONGVIDEOBENCH_MAX_SAMPLES", 0),
+ }
+ if task == "lvbench":
+ return {
+ "setting": env_required("LVBENCH_SETTING"),
+ "data_file": env_required("LVBENCH_DATA_FILE"),
+ "video_root": env_value("LVBENCH_VIDEO_ROOT", ""),
+ "batch_size": env_int("LVBENCH_BATCH_SIZE", 4),
+ "max_model_len": env_int("LVBENCH_MAX_MODEL_LEN", 65536),
+ "max_new_tokens": env_int("LVBENCH_MAX_NEW_TOKENS", 64),
+ "max_num_batched_tokens": env_int("LVBENCH_MAX_NUM_BATCHED_TOKENS", 65536),
+ "gpu_memory_utilization": env_float("LVBENCH_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("LVBENCH_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("LVBENCH_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("LVBENCH_VIDEO_TOTAL_PIXELS", 0),
+ "max_frames": env_int("LVBENCH_MAX_FRAMES", 128),
+ "fps": env_int("LVBENCH_FPS", 2),
+ "prompt_mode": str(env_value("LVBENCH_PROMPT_MODE", "default")),
+ "enable_thinking": env_value("LVBENCH_ENABLE_THINKING", "false").lower() == "true",
+ "temperature": env_float("LVBENCH_TEMPERATURE", 0.0),
+ "top_p": env_float("LVBENCH_TOP_P", 1.0),
+ "top_k": env_int("LVBENCH_TOP_K", -1),
+ "presence_penalty": env_float("LVBENCH_PRESENCE_PENALTY", 0.0),
+ "min_p": env_float("LVBENCH_MIN_P", 0.0),
+ }
+ if task == "mlvu":
+ return {
+ "setting": env_required("MLVU_SETTING"),
+ "data_file": env_required("MLVU_DATA_FILE"),
+ "video_root": env_value("MLVU_VIDEO_ROOT", ""),
+ "batch_size": env_int("MLVU_BATCH_SIZE", 4),
+ "max_model_len": env_int("MLVU_MAX_MODEL_LEN", 65536),
+ "max_new_tokens": env_int("MLVU_MAX_NEW_TOKENS", 64),
+ "max_num_batched_tokens": env_int("MLVU_MAX_NUM_BATCHED_TOKENS", 65536),
+ "gpu_memory_utilization": env_float("MLVU_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("MLVU_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("MLVU_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("MLVU_VIDEO_TOTAL_PIXELS", 0),
+ "max_frames": env_int("MLVU_MAX_FRAMES", 128),
+ "fps": env_int("MLVU_FPS", 2),
+ "prompt_mode": str(env_value("MLVU_PROMPT_MODE", "default")),
+ "enable_thinking": env_value("MLVU_ENABLE_THINKING", "false").lower() == "true",
+ "temperature": env_float("MLVU_TEMPERATURE", 0.0),
+ "top_p": env_float("MLVU_TOP_P", 1.0),
+ "top_k": env_int("MLVU_TOP_K", -1),
+ "presence_penalty": env_float("MLVU_PRESENCE_PENALTY", 0.0),
+ "min_p": env_float("MLVU_MIN_P", 0.0),
+ "max_samples": env_int("MLVU_MAX_SAMPLES", 0),
+ }
+ if task == "vsi":
+ return {
+ "setting": env_required("VSI_SETTING"),
+ "data_file": env_required("VSI_DATA_FILE"),
+ "preprocessed_video_dir": env_required("VSI_PREPROCESSED_VIDEO_DIR"),
+ "batch_size": env_int("VSI_BATCH_SIZE", 16),
+ "max_model_len": env_int("VSI_MAX_MODEL_LEN", 32768),
+ "max_new_tokens": env_int("VSI_MAX_NEW_TOKENS", 1024),
+ "gpu_memory_utilization": env_float("VSI_GPU_MEMORY_UTILIZATION", 0.90),
+ "video_min_pixels": env_int("VSI_VIDEO_MIN_PIXELS", 65536),
+ "video_max_pixels": env_int("VSI_VIDEO_MAX_PIXELS", 262144),
+ "video_total_pixels": env_int("VSI_VIDEO_TOTAL_PIXELS", 16777216),
+ "max_frames": env_int("VSI_MAX_FRAMES", 128),
+ "fps": env_int("VSI_FPS", 2),
+ }
+ if task == "mmsi":
+ return {
+ "setting": env_required("MMSI_SETTING"),
+ "data_file": env_required("MMSI_DATA_FILE"),
+ "max_images": env_int("MMSI_MAX_IMAGES", 8),
+ "batch_size": env_int("MMSI_BATCH_SIZE", 16),
+ "max_model_len": env_int("MMSI_MAX_MODEL_LEN", 32768),
+ "max_new_tokens": env_int("MMSI_MAX_NEW_TOKENS", 1024),
+ "max_num_batched_tokens": env_int("MMSI_MAX_NUM_BATCHED_TOKENS", 32768),
+ "gpu_memory_utilization": env_float("MMSI_GPU_MEMORY_UTILIZATION", 0.90),
+ }
+ if task == "mindcube":
+ return {
+ "setting": env_required("MINDCUBE_SETTING"),
+ "data_file": env_required("MINDCUBE_DATA_FILE"),
+ "max_images": env_int("MINDCUBE_MAX_IMAGES", 3),
+ "expected_samples": env_int(
+ "MINDCUBE_EXPECTED_SAMPLES", 1050
+ ),
+ "batch_size": env_int("MINDCUBE_BATCH_SIZE", 16),
+ "max_model_len": env_int("MINDCUBE_MAX_MODEL_LEN", 32768),
+ "max_new_tokens": env_int("MINDCUBE_MAX_NEW_TOKENS", 1024),
+ "max_num_batched_tokens": env_int("MINDCUBE_MAX_NUM_BATCHED_TOKENS", 32768),
+ "gpu_memory_utilization": env_float("MINDCUBE_GPU_MEMORY_UTILIZATION", 0.90),
+ }
+ if task == "spatial_grounding":
+ return {
+ "setting": env_required("SPATIAL_GROUNDING_SETTING"),
+ "data_file": "",
+ "processor_path": env_value("SPATIAL_GROUNDING_PROCESSOR_PATH", os.environ.get("MODEL_PATH", "")),
+ "bench_dir": env_required("SPATIAL_GROUNDING_BENCH_DIR"),
+ "datasets": env_value(
+ "SPATIAL_GROUNDING_DATASETS",
+ "refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test",
+ ),
+ "prompt_style": env_value("SPATIAL_GROUNDING_PROMPT_STYLE", "qwen_native"),
+ "coord_system": env_value("SPATIAL_GROUNDING_COORD_SYSTEM", "norm1000"),
+ "bbox_select": env_value("SPATIAL_GROUNDING_BBOX_SELECT", "first"),
+ "enable_thinking": env_value("SPATIAL_GROUNDING_ENABLE_THINKING", "false").lower() == "true",
+ "min_tokens": env_int("SPATIAL_GROUNDING_MIN_TOKENS", 64),
+ "total_tokens": env_int("SPATIAL_GROUNDING_TOTAL_TOKENS", 1024),
+ "batch_size": env_int("SPATIAL_GROUNDING_BATCH_SIZE", 64),
+ "max_model_len": env_int("SPATIAL_GROUNDING_MAX_MODEL_LEN", 32768),
+ "max_new_tokens": env_int("SPATIAL_GROUNDING_MAX_NEW_TOKENS", 1024),
+ "max_num_batched_tokens": env_int("SPATIAL_GROUNDING_MAX_NUM_BATCHED_TOKENS", 32768),
+ "gpu_memory_utilization": env_float("SPATIAL_GROUNDING_GPU_MEMORY_UTILIZATION", 0.85),
+ "max_samples": env_int("SPATIAL_GROUNDING_MAX_SAMPLES", 0),
+ }
+ if task == "tracking":
+ return {
+ "setting": env_required("TRACKING_SETTING"),
+ "data_file": "",
+ "processor_path": env_value("TRACKING_PROCESSOR_PATH", os.environ.get("MODEL_PATH", "")),
+ "bench_dir": env_required("TRACKING_BENCH_DIR"),
+ "base_prefix": env_value("TRACKING_BASE_PREFIX", env_required("TRACKING_BENCH_DIR")),
+ "datasets": env_value("TRACKING_DATASETS", "eval_got10k"),
+ "video_min_pixels": env_int("TRACKING_VIDEO_MIN_PIXELS", 4096),
+ "video_max_pixels": env_int("TRACKING_VIDEO_MAX_PIXELS", 786432),
+ "video_total_pixels": env_int("TRACKING_VIDEO_TOTAL_PIXELS", 8388608),
+ "max_frames": env_int("TRACKING_MAX_FRAMES", 32),
+ "fps": env_int("TRACKING_FPS", 1),
+ "batch_size": env_int("TRACKING_BATCH_SIZE", 64),
+ "max_model_len": env_int("TRACKING_MAX_MODEL_LEN", 32768),
+ "max_new_tokens": env_int("TRACKING_MAX_NEW_TOKENS", 8192),
+ "max_num_batched_tokens": env_int("TRACKING_MAX_NUM_BATCHED_TOKENS", 32768),
+ "gpu_memory_utilization": env_float("TRACKING_GPU_MEMORY_UTILIZATION", 0.95),
+ "enable_thinking": env_value("TRACKING_ENABLE_THINKING", "false").lower() == "true",
+ "prompt_mode": env_value("TRACKING_PROMPT_MODE", "default"),
+ "chunked_reprompt": env_int("TRACKING_CHUNKED_REPROMPT", 0),
+ "reprompt_use_gt_first_box": env_value("TRACKING_REPROMPT_USE_GT_FIRST_BOX", "0") in {"1", "true", "True"},
+ "max_samples": env_int("TRACKING_MAX_SAMPLES", 0),
+ }
+ if task == "stvg":
+ return {
+ "setting": env_required("STVG_SETTING"),
+ "data_file": "",
+ "processor_path": env_value("STVG_PROCESSOR_PATH", os.environ.get("MODEL_PATH", "")),
+ "bench_dir": env_required("STVG_BENCH_DIR"),
+ "base_prefix": env_value("STVG_BASE_PREFIX", env_required("STVG_BENCH_DIR")),
+ "datasets": env_value("STVG_DATASETS", "eval_stvg"),
+ "video_min_pixels": env_int("STVG_VIDEO_MIN_PIXELS", 65536),
+ "video_max_pixels": env_int("STVG_VIDEO_MAX_PIXELS", 393216),
+ "video_total_pixels": env_int("STVG_VIDEO_TOTAL_PIXELS", 10485760),
+ "max_frames": env_int("STVG_MAX_FRAMES", 128),
+ "fps": env_int("STVG_FPS", 2),
+ "batch_size": env_int("STVG_BATCH_SIZE", 64),
+ "max_model_len": env_int("STVG_MAX_MODEL_LEN", 65536),
+ "max_new_tokens": env_int("STVG_MAX_NEW_TOKENS", 2048),
+ "max_num_batched_tokens": env_int("STVG_MAX_NUM_BATCHED_TOKENS", 65536),
+ "gpu_memory_utilization": env_float("STVG_GPU_MEMORY_UTILIZATION", 0.85),
+ "enable_thinking": env_value("STVG_ENABLE_THINKING", "false").lower() == "true",
+ "prompt_mode": env_value("STVG_PROMPT_MODE", "train_stvg"),
+ "max_samples": env_int("STVG_MAX_SAMPLES", 0),
+ }
+ raise ValueError(task)
+
+
+# ---------------------------------------------------------------------------
+# VideoMME
+# ---------------------------------------------------------------------------
+
+
+def videomme_prompt(rec: Dict[str, Any], prompt_mode: str = "default") -> str:
+ question = str(rec.get("question") or rec.get("problem") or "").strip()
+ options = rec.get("options") or rec.get("choices") or []
+ if isinstance(options, dict):
+ options = [str(options[k]) for k in sorted(options)]
+ elif isinstance(options, str):
+ options = [x.strip() for x in options.split("\n") if x.strip()]
+ else:
+ options = [str(x) for x in options]
+ tail = VIDEO_QA_MC_TAIL
+ if prompt_mode == "explicit_ah":
+ tail = (
+ "There are 8 options, A through H. Answer with exactly one option "
+ "letter from A, B, C, D, E, F, G, H within ... tags. "
+ "Example: H "
+ )
+ elif prompt_mode == "careful_ah":
+ tail = (
+ "Carefully watch the video and compare all options. The correct answer "
+ "may be any option from A, B, C, D, E, F, G, or H. Do not favor earlier "
+ "options. Answer with exactly one option letter within ... "
+ "tags. Example: G "
+ )
+ return (
+ f"{question}\n"
+ f"Options:\n" + "\n".join(options) + "\n"
+ f"{tail}"
+ )
+
+
+def videomme_preproc_path(rec: Dict[str, Any], root: str) -> str:
+ value = rec.get("preprocessed_video") or rec.get("preprocessed_video_path")
+ if not value:
+ return ""
+ value = str(value)
+ if os.path.isabs(value):
+ return value
+ return os.path.join(root, value)
+
+
+def videomme_video_path(rec: Dict[str, Any], args) -> str:
+ raw = rec.get("path") or rec.get("video_path") or rec.get("file_name")
+ videos = rec.get("videos")
+ if not raw and isinstance(videos, list) and videos:
+ raw = videos[0]
+ video_obj = rec.get("video")
+ if not raw and isinstance(video_obj, dict):
+ raw = video_obj.get("path") or video_obj.get("filename")
+ elif not raw and isinstance(video_obj, str):
+ raw = video_obj
+
+ candidates: List[str] = []
+ for base in [getattr(args, "video_dir", ""), getattr(args, "video_base", "")]:
+ if not base:
+ continue
+ if raw:
+ rel = str(raw).lstrip("./").lstrip("/")
+ candidates.extend([os.path.join(base, rel), os.path.join(base, os.path.basename(rel))])
+ video_id = str(rec.get("videoID") or rec.get("video_id") or "").strip()
+ if video_id:
+ stem, ext = os.path.splitext(video_id)
+ names = [video_id] if ext else [video_id + e for e in (".mp4", ".MP4", ".mkv", ".avi", ".mov", ".webm")]
+ for name in names:
+ candidates.extend([os.path.join(base, name), os.path.join(base, "videos", name), os.path.join(base, "data", name)])
+ if raw and os.path.isabs(str(raw)):
+ candidates.insert(0, str(raw))
+
+ seen = set()
+ for path in candidates:
+ if path and path not in seen:
+ seen.add(path)
+ if os.path.isfile(path):
+ return path
+ return ""
+
+
+def videomme_video_content(path: str, args) -> Dict[str, Any]:
+ item: Dict[str, Any] = {
+ "type": "video",
+ "video": path,
+ "max_pixels": args.video_max_pixels,
+ "max_frames": args.max_frames,
+ "fps": args.fps,
+ }
+ if args.video_min_pixels > 0:
+ item["min_pixels"] = args.video_min_pixels
+ if args.video_total_pixels > 0:
+ item["total_pixels"] = args.video_total_pixels
+ return item
+
+
+def worker_videomme(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = read_json_or_jsonl(args.data_file)
+ records.sort(key=lambda r: (str(r.get("videoID") or r.get("video_id") or ""), str(r.get("question_id") or r.get("id") or "")))
+ answer_filter = {c for c in str(getattr(args, "answer_filter", "") or "").upper() if c in CHOICES}
+ if answer_filter:
+ records = [
+ r for r in records
+ if extract_mcq_answer(str(r.get("answer") or r.get("ground_truth") or ""), CHOICES) in answer_filter
+ ]
+ if args.max_samples > 0:
+ records = records[: args.max_samples]
+ records = shard_records(records, args.chunk, args.index)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", do_resize=False, trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ patch_size = getattr(processor.image_processor, "patch_size", 16)
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ )
+ sampling = SamplingParams(max_tokens=args.max_new_tokens, temperature=0.0, top_p=1.0, top_k=-1)
+ results = []
+
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs = []
+ keep = []
+ for rec in batch:
+ preprocessed_path = videomme_preproc_path(
+ rec,
+ args.preprocessed_video_dir,
+ )
+ use_preprocessed = bool(
+ args.preprocessed_video_dir
+ and args.preprocessed_video_dir != "__none__"
+ and preprocessed_path
+ and os.path.isfile(preprocessed_path)
+ )
+ path = (
+ preprocessed_path
+ if use_preprocessed
+ else videomme_video_path(rec, args)
+ )
+ if not path:
+ print(f"[warn] VideoMME video not found: video_id={rec.get('videoID') or rec.get('video_id')}", flush=True)
+ continue
+ messages = [{"role": "user", "content": [
+ {"type": "video", "video": path} if use_preprocessed else videomme_video_content(path, args),
+ {"type": "text", "text": videomme_prompt(rec, getattr(args, "prompt_mode", "default"))},
+ ]}]
+ if use_preprocessed:
+ inputs.append(prepare_preprocessed_video(messages, processor, path))
+ else:
+ inputs.append(prepare_raw_video(messages, processor, patch_size))
+ keep.append(rec)
+ return keep, inputs
+
+ for keep, inputs in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling) if inputs else []
+ for rec, out in zip(keep, outs):
+ raw = out.outputs[0].text
+ options = rec.get("options") or rec.get("choices") or []
+ labels = CHOICES[:len(options)] if isinstance(options, list) and options else list("ABCD")
+ pred = extract_mcq_answer(raw, labels)
+ gt = require_mcq_ground_truth(rec, labels, task=args.task)
+ results.append({
+ "question_id": rec.get("question_id") or rec.get("id"),
+ "videoID": rec.get("videoID") or rec.get("video_id"),
+ "duration": rec.get("duration", "unknown"),
+ "category": rec.get("domain") or rec.get("category") or "unknown",
+ "sub_category": rec.get("sub_category", "unknown"),
+ "task_category": rec.get("task_type") or rec.get("task_category") or "unknown",
+ "question": rec.get("question") or rec.get("problem"),
+ "answer": gt,
+ "pred_answer": pred,
+ "score": 1.0 if pred and gt and pred == gt else 0.0,
+ "raw_prediction": raw,
+ })
+ write_json(args.output_dir, f"results_{args.task}_shard{args.index}.json", results)
+ write_json(args.output_dir, f"summary_shard{args.index}.json", aggregate_videomme(results))
+
+
+def aggregate_videomme(samples: List[Dict[str, Any]]) -> Dict[str, Any]:
+ def group(key: str, order: Optional[List[str]] = None):
+ d = defaultdict(lambda: {"correct": 0, "total": 0})
+ for s in samples:
+ name = str(s.get(key) or "unknown")
+ d[name]["total"] += 1
+ d[name]["correct"] += int(s.get("score", 0))
+ names = order or sorted(d)
+ return {k: {"accuracy": pct(v["correct"], v["total"]), "correct": v["correct"], "total": v["total"]} for k, v in ((k, d[k]) for k in names if k in d)}
+
+ correct = sum(int(s.get("score", 0)) for s in samples)
+ parsed = sum(1 for s in samples if s.get("pred_answer"))
+ labeled = sum(1 for s in samples if s.get("answer"))
+ return {
+ "num_samples": len(samples),
+ "correct": correct,
+ "accuracy": pct(correct, len(samples)),
+ "parse_rate": pct(parsed, len(samples)),
+ "ground_truth_rate": pct(labeled, len(samples)),
+ "by_duration": group("duration", ["short", "medium", "long"]),
+ "by_category": group("category"),
+ "by_sub_category": group("sub_category"),
+ "by_task_category": group("task_category"),
+ }
+
+
+# ---------------------------------------------------------------------------
+# VideoMMMU
+# ---------------------------------------------------------------------------
+
+
+def videommmu_use_separate_images(root: str) -> bool:
+ base = Path(root)
+ images_dir = base / "images"
+ if images_dir.is_dir() and any(images_dir.glob("*.png")):
+ return True
+ ot_dir = base / "Adaptation" / "test-00000-of-00001"
+ return ot_dir.is_dir() and any(ot_dir.glob("*.png"))
+
+
+def videommmu_index(root: str, use_separate_images: bool = False) -> Dict[str, str]:
+ base = Path(root)
+ index: Dict[str, str] = {}
+ suffixes = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".png", ".jpg", ".jpeg", ".webp"}
+ for path in base.glob("*/*"):
+ if not path.is_file() or path.suffix.lower() not in suffixes:
+ continue
+ if use_separate_images and path.parent.name == "question_only":
+ continue
+ if use_separate_images:
+ if path.name not in index:
+ index[path.name] = str(path)
+ continue
+ # Legacy fallback: prefer question_only/*_image.mp4 when separate PNGs are unavailable.
+ if path.parent.name == "question_only" or path.name not in index:
+ index[path.name] = str(path)
+ if path.parent.name == "question_only" and path.stem.endswith("_image"):
+ original_name = path.stem[: -len("_image")] + path.suffix
+ index[original_name] = str(path)
+ for path in base.glob("images/*"):
+ if path.is_file() and path.suffix.lower() in suffixes:
+ index[path.name] = str(path)
+ for path in (base / "Adaptation" / "test-00000-of-00001").glob("*"):
+ if path.is_file() and path.suffix.lower() in suffixes:
+ index[path.name] = str(path)
+ for path in base.glob("*/images/*"):
+ if path.is_file() and path.suffix.lower() in suffixes:
+ index.setdefault(path.name, str(path))
+ return index
+
+
+def videommmu_resolve(path_value: Any, root: str, index: Dict[str, str]) -> str:
+ raw = str(path_value or "").strip()
+ raw = raw[2:] if raw.startswith("./") else raw
+ if not raw:
+ return ""
+ path = Path(raw)
+ if path.is_absolute() and path.is_file():
+ return str(path)
+ direct = Path(root) / raw
+ if direct.is_file():
+ return str(direct)
+ return index.get(path.name, str(direct))
+
+
+def videommmu_resolve_image(rec: Dict[str, Any], root: str, index: Dict[str, str]) -> str:
+ # Only Adaptation questions carry a separate image. Comprehension/Perception
+ # records have an empty image_path and must stay pure-video; do not fall back
+ # to same-video figures, or we would staple the Adaptation image onto them.
+ declared = str(rec.get("image_path") or rec.get("additional_path") or "").strip()
+ if not declared:
+ return ""
+
+ for key in ("image_path", "additional_path"):
+ resolved = videommmu_resolve(rec.get(key), root, index)
+ if resolved and os.path.isfile(resolved):
+ return resolved
+
+ video_stem = Path(str(rec.get("path") or "")).stem
+ if video_stem:
+ by_stem = Path(root) / "images" / f"{video_stem}.png"
+ if by_stem.is_file():
+ return str(by_stem)
+
+ problem_id = rec.get("problem_id")
+ if problem_id is not None and str(problem_id).strip():
+ by_id = Path(root) / "Adaptation" / "test-00000-of-00001" / f"{int(problem_id)}.png"
+ if by_id.is_file():
+ return str(by_id)
+ return ""
+
+
+def videommmu_safe_total_frames(video_path: str, cache: Dict[str, int]) -> int:
+ if video_path in cache:
+ return cache[video_path]
+ counts: List[int] = []
+ try:
+ from decord import VideoReader, cpu
+
+ counts.append(len(VideoReader(video_path, ctx=cpu(0))))
+ except Exception:
+ pass
+ try:
+ import torchvision.io as io
+
+ video, _, _ = io.read_video(video_path, output_format="TCHW")
+ counts.append(int(video.size(0)))
+ except Exception:
+ pass
+ cache[video_path] = min(counts) if counts else 0
+ return cache[video_path]
+
+
+def videommmu_uses_appended_image(
+ rec: Dict[str, Any],
+ video_path: str,
+ has_separate_image: bool = False,
+) -> bool:
+ if has_separate_image:
+ return False
+ if rec.get("image_path") or rec.get("additional_path"):
+ return True
+ return "/question_only/" in video_path.replace("\\", "/")
+
+
+def videommmu_video_item(
+ rec: Dict[str, Any],
+ video_path: str,
+ args,
+ frame_count_cache: Dict[str, int],
+) -> Dict[str, Any]:
+ item: Dict[str, Any] = {
+ "type": "video",
+ "video": video_path,
+ "min_pixels": args.video_min_pixels,
+ "max_pixels": args.video_max_pixels,
+ }
+ if args.video_total_pixels > 0:
+ item["total_pixels"] = args.video_total_pixels
+
+ # Adaptation questions either use a separate PNG image or, as a legacy
+ # fallback, question_only/*_image.mp4 with the figure appended. Use
+ # fps+max_frames for both so we do not scan full source videos just to cap
+ # nframes.
+ if (
+ rec.get("_has_separate_image")
+ or videommmu_uses_appended_image(rec, video_path, bool(rec.get("_has_separate_image")))
+ ):
+ item["fps"] = args.fps
+ item["max_frames"] = args.max_frames
+ return item
+
+ if args.max_frames > 0:
+ total = videommmu_safe_total_frames(video_path, frame_count_cache)
+ if total > 0:
+ nframes = min(args.max_frames, total)
+ nframes = max(2, nframes - (nframes % 2))
+ item["nframes"] = nframes
+ else:
+ item["nframes"] = args.max_frames
+ else:
+ item["fps"] = args.fps
+ return item
+
+
+# OneThinker (Evaluation/Eval/eval_bench.py) answer-format tails, ported but
+# WITHOUT the reasoning instruction (no-think / direct-answer mode).
+ONETHINK_MC_TAIL = (
+ "Please answer this question based on the visual content.\n"
+ "Answer directly without any explanation or reasoning. "
+ "Output only the single option letter (e.g., A, B, C, D, etc.) "
+ "within the ... tags.\n"
+ "Example:\nA "
+)
+ONETHINK_NUM_TAIL = (
+ "Please answer this question based on the visual content.\n"
+ "Answer directly without any explanation or reasoning. "
+ "Output only the numerical value within the ... tags.\n"
+ "Example:\n3.14 "
+)
+
+
+# OneThinker's verbatim CoT wrapper (Evaluation/Eval/eval_bench.py QUESTION_TEMPLATE
+# + TYPE_TEMPLATE). Use prompt_mode=onethink_cot with a large max_new_tokens (8192).
+ONETHINK_COT_TEMPLATE = (
+ "{Question}\n"
+ "Please answer this question based on the visual content."
+ "Provide your thinking process between the and tags, and then give your final answer between the and tags."
+ "At the end, you must output the final answer in the format:\n"
+ " \n"
+)
+ONETHINK_COT_MC_TAIL = (
+ "Please provide only the single option letter (e.g., A, B, C, D, etc.) "
+ "within the ... tags.\n"
+ "Example:\nA "
+)
+ONETHINK_COT_NUM_TAIL = (
+ "Please provide only the numerical value within the ... tags.\n"
+ "Example:\n3.14 "
+)
+
+
+def videommmu_prompt_onethink(rec: Dict[str, Any], cot: bool = False) -> Tuple[str, List[str]]:
+ question = str(rec.get("problem") or rec.get("question") or "").strip()
+ options = normalize_choices(rec.get("options"))
+ labels = VIDEOMMMU_CHOICES[:len(options)] if options else list("ABCD")
+ is_numerical = str(rec.get("problem_type") or "").lower() == "numerical"
+ if options and not is_numerical and "Options:" not in question:
+ question += "\nOptions:\n" + "\n".join(str(x) for x in options)
+ if cot:
+ tail = ONETHINK_COT_NUM_TAIL if is_numerical else ONETHINK_COT_MC_TAIL
+ prompt = ONETHINK_COT_TEMPLATE.format(Question=question) + tail
+ else:
+ tail = ONETHINK_NUM_TAIL if is_numerical else ONETHINK_MC_TAIL
+ prompt = f"{question}\n{tail}"
+ return prompt, ([] if is_numerical else labels)
+
+
+# Official Qwen3.5 benchmarking format (model card "Best Practices"): the model
+# reasons in its native thinking mode (enable_thinking=True) and we only fix the
+# final-answer format. Do NOT forbid reasoning here.
+def videommmu_prompt_qwen(rec: Dict[str, Any]) -> Tuple[str, List[str]]:
+ question = str(rec.get("problem") or rec.get("question") or "").strip()
+ options = normalize_choices(rec.get("options"))
+ labels = VIDEOMMMU_CHOICES[:len(options)] if options else list("ABCD")
+ is_numerical = str(rec.get("problem_type") or "").lower() == "numerical"
+ if options and not is_numerical and "Options:" not in question:
+ question += "\nOptions:\n" + "\n".join(str(x) for x in options)
+ if is_numerical:
+ tail = (
+ "Please answer the question based on the visual content. "
+ "Put your final numerical answer inside tags, "
+ "e.g., 3.14 ."
+ )
+ return f"{question}\n{tail}", []
+ tail = (
+ "Please answer the question based on the visual content. "
+ "Put only the choice letter inside tags, "
+ "e.g., C ."
+ )
+ return f"{question}\n{tail}", labels
+
+
+def videommmu_prompt(
+ rec: Dict[str, Any],
+ has_separate_image: bool = False,
+ prompt_mode: str = "default",
+) -> Tuple[str, List[str]]:
+ if prompt_mode == "onethink":
+ return videommmu_prompt_onethink(rec, cot=False)
+ if prompt_mode == "onethink_cot":
+ return videommmu_prompt_onethink(rec, cot=True)
+ if prompt_mode == "qwen":
+ return videommmu_prompt_qwen(rec)
+ question = str(rec.get("problem") or rec.get("question") or "").strip()
+ options = normalize_choices(rec.get("options"))
+ labels = VIDEOMMMU_CHOICES[:len(options)] if options else list("ABCD")
+ if options and "Options:" not in question:
+ question += "\nOptions:\n" + "\n".join(str(x) for x in options)
+ has_appended_image = bool(rec.get("image_path") or rec.get("additional_path")) and not has_separate_image
+ if has_appended_image:
+ question = re.sub(r"", "the image at the end of the video", question, flags=re.IGNORECASE)
+ question = "The image for this question is at the end of the video.\n" + question
+ else:
+ question = re.sub(r"", "the image", question, flags=re.IGNORECASE)
+ if str(rec.get("problem_type") or "").lower() == "numerical":
+ return (
+ f"{question}\n"
+ "Answer with the final number in the format NUMBER . Do not explain.",
+ [],
+ )
+ return (
+ f"{question}\n{VIDEO_QA_MC_TAIL}",
+ labels,
+ )
+
+
+def videommmu_extract_number(text: str) -> str:
+ text = strip_answer_tags(strip_think_block(text))
+ match = re.search(r"[-+]?\d+(?:,\d{3})*(?:\.\d+)?(?:[eE][-+]?\d+)?", text)
+ return match.group(0).replace(",", "") if match else ""
+
+
+def videommmu_score(rec: Dict[str, Any], raw: str, labels: List[str]) -> Tuple[str, str, float]:
+ gt_text = str(rec.get("solution") or rec.get("answer") or rec.get("ground_truth") or "")
+ if str(rec.get("problem_type") or "").lower() == "numerical":
+ pred = videommmu_extract_number(raw)
+ gt = videommmu_extract_number(gt_text)
+ if not pred or not gt:
+ return pred, gt, 0.0
+ try:
+ pv, gv = float(pred), float(gt)
+ except Exception:
+ return pred, gt, 0.0
+ return pred, gt, 1.0 if round(pv, 2) == round(gv, 2) else 0.0
+ pred = extract_mcq_answer(raw, labels)
+ gt = require_mcq_ground_truth(rec, labels, task="videommmu")
+ return pred, gt, 1.0 if pred and gt and pred == gt else 0.0
+
+
+def prepare_video_image(
+ messages: List[Dict[str, Any]],
+ processor,
+ media_cache_key: Optional[Tuple[Any, ...]] = None,
+ media_cache: Optional[Dict[Tuple[Any, ...], Tuple[Dict[str, Any], Dict[str, Any]]]] = None,
+ enable_thinking: bool = False,
+) -> Dict[str, Any]:
+ from qwen_vl_utils import process_vision_info
+
+ text = render_chat_prompt(messages, processor, enable_thinking)
+ cached = media_cache.get(media_cache_key) if media_cache is not None and media_cache_key is not None else None
+ if cached is None:
+ image_inputs, video_inputs, video_kwargs = process_vision_info(
+ messages,
+ return_video_kwargs=True,
+ return_video_metadata=True,
+ )
+ multi_modal_data: Dict[str, Any] = {}
+ if image_inputs:
+ multi_modal_data["image"] = image_inputs
+ if video_inputs:
+ multi_modal_data["video"] = video_inputs
+ if media_cache is not None and media_cache_key is not None:
+ # Keep only the most recent media item. Records are path-sorted, so
+ # this avoids repeated decoding for multiple questions on one video
+ # without growing CPU memory across the whole shard.
+ media_cache.clear()
+ media_cache[media_cache_key] = (multi_modal_data, video_kwargs)
+ else:
+ multi_modal_data, video_kwargs = cached
+ return {
+ "prompt": text,
+ "multi_modal_data": multi_modal_data,
+ "mm_processor_kwargs": video_kwargs,
+ }
+
+
+def worker_videommmu(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = read_json_or_jsonl(args.data_file)
+ records.sort(key=lambda r: (str(r.get("path") or ""), str(r.get("problem_id") or "")))
+ records = shard_records(records, args.chunk, args.index)
+ use_separate_images = videommmu_use_separate_images(args.preprocessed_video_dir)
+ media_index = videommmu_index(args.preprocessed_video_dir, use_separate_images=use_separate_images)
+ if use_separate_images:
+ print("[videommmu] using subject videos + separate PNG images", flush=True)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ )
+ sampling_kwargs: Dict[str, Any] = {
+ "max_tokens": args.max_new_tokens,
+ "temperature": args.temperature,
+ "top_p": args.top_p,
+ "top_k": args.top_k,
+ }
+ if args.presence_penalty:
+ sampling_kwargs["presence_penalty"] = args.presence_penalty
+ if args.min_p:
+ sampling_kwargs["min_p"] = args.min_p
+ sampling = SamplingParams(**sampling_kwargs)
+ print(f"[videommmu] sampling={sampling_kwargs} enable_thinking={args.enable_thinking} prompt_mode={args.prompt_mode}", flush=True)
+ results = []
+ media_cache: Dict[Tuple[Any, ...], Tuple[Dict[str, Any], Dict[str, Any]]] = {}
+ frame_count_cache: Dict[str, int] = {}
+
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs, kept, prompts, labels = [], [], {}, {}
+ for j, rec in enumerate(batch):
+ try:
+ video_path = videommmu_resolve(rec.get("path"), args.preprocessed_video_dir, media_index)
+ if not os.path.isfile(video_path):
+ print(f"[warn] skip {rec.get('problem_id')}: video not found: {video_path}", flush=True)
+ continue
+ image_path = videommmu_resolve_image(rec, args.preprocessed_video_dir, media_index)
+ has_separate_image = bool(image_path)
+ rec = {**rec, "_has_separate_image": has_separate_image}
+ prompt, labs = videommmu_prompt(rec, has_separate_image=has_separate_image, prompt_mode=args.prompt_mode)
+ video_item = videommmu_video_item(rec, video_path, args, frame_count_cache)
+ content: List[Dict[str, Any]] = [video_item]
+ if has_separate_image:
+ image_item: Dict[str, Any] = {"type": "image", "image": image_path}
+ if args.image_max_pixels > 0:
+ image_item["max_pixels"] = args.image_max_pixels
+ content.append(image_item)
+ content.append({"type": "text", "text": prompt})
+ messages = [{"role": "user", "content": content}]
+ media_key = (
+ video_path,
+ image_path if image_path and os.path.isfile(image_path) else "",
+ args.video_min_pixels,
+ args.video_max_pixels,
+ args.video_total_pixels,
+ args.image_max_pixels,
+ video_item.get("nframes", 0),
+ video_item.get("fps", 0),
+ video_item.get("max_frames", 0),
+ )
+ inputs.append(prepare_video_image(messages, processor, media_key, media_cache, enable_thinking=args.enable_thinking))
+ kept.append(j)
+ prompts[j] = prompt
+ labels[j] = labs
+ except Exception as e:
+ print(f"[warn] skip {start + j}: {type(e).__name__}: {e}", flush=True)
+ return start, batch, inputs, kept, prompts, labels
+
+ for start, batch, inputs, kept, prompts, labels in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling) if inputs else []
+ for j, out in zip(kept, outs):
+ rec = batch[j]
+ raw = out.outputs[0].text
+ pred, gt, score = videommmu_score(rec, raw, labels[j])
+ video_name = Path(str(rec.get("path") or "")).name
+ group = video_name.split("_")[1] if "_" in video_name else "unknown"
+ results.append({
+ "id": str(rec.get("problem_id") or start + j),
+ "bench": "videommmu",
+ "group": group,
+ "question": rec.get("problem") or rec.get("question"),
+ "prompt": prompts[j],
+ "answer": gt,
+ "pred_answer": pred,
+ "raw_prediction": raw,
+ "score": score,
+ })
+ write_json(args.output_dir, f"results_videommmu_shard{args.index}.json", results)
+ write_json(args.output_dir, f"summary_shard{args.index}.json", aggregate_image_mc("videommmu", results))
+
+
+# ---------------------------------------------------------------------------
+# MMVU (multiple-choice, expert-level multi-discipline video understanding)
+# ---------------------------------------------------------------------------
+
+
+def mmvu_labels(rec: Dict[str, Any]) -> List[str]:
+ labs = rec.get("labels")
+ if isinstance(labs, list) and labs:
+ return [str(x) for x in labs]
+ options = rec.get("options") or []
+ return CHOICES[: len(options)] if options else list("ABCDE")
+
+
+def mmvu_prompt(rec: Dict[str, Any], prompt_mode: str = "default") -> Tuple[str, List[str]]:
+ question = str(rec.get("problem") or rec.get("question") or "").strip()
+ options = rec.get("options") or []
+ labels = mmvu_labels(rec)
+ if options and "Options:" not in question:
+ question += "\nOptions:\n" + "\n".join(str(x) for x in options)
+ if prompt_mode == "qwen":
+ tail = (
+ "Please answer the question based on the visual content. "
+ "Put only the choice letter inside tags, e.g., C ."
+ )
+ else:
+ tail = VIDEO_QA_MC_TAIL
+ return f"{question}\n{tail}", labels
+
+
+def mmvu_video_item(rec: Dict[str, Any], video_path: str, args) -> Dict[str, Any]:
+ item: Dict[str, Any] = {
+ "type": "video",
+ "video": video_path,
+ "min_pixels": args.video_min_pixels,
+ "max_pixels": args.video_max_pixels,
+ "fps": args.fps,
+ "max_frames": args.max_frames,
+ }
+ if args.video_total_pixels > 0:
+ item["total_pixels"] = args.video_total_pixels
+ return item
+
+
+def worker_mmvu(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = read_json_or_jsonl(args.data_file)
+ records.sort(key=lambda r: (str(r.get("path") or ""), str(r.get("id") or "")))
+ if args.max_samples > 0:
+ records = records[: args.max_samples]
+ records = shard_records(records, args.chunk, args.index)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ )
+ sampling_kwargs: Dict[str, Any] = {
+ "max_tokens": args.max_new_tokens,
+ "temperature": args.temperature,
+ "top_p": args.top_p,
+ "top_k": args.top_k,
+ }
+ if args.presence_penalty:
+ sampling_kwargs["presence_penalty"] = args.presence_penalty
+ if args.min_p:
+ sampling_kwargs["min_p"] = args.min_p
+ sampling = SamplingParams(**sampling_kwargs)
+ print(f"[mmvu] sampling={sampling_kwargs} enable_thinking={args.enable_thinking} prompt_mode={args.prompt_mode}", flush=True)
+ results = []
+ media_cache: Dict[Tuple[Any, ...], Tuple[Dict[str, Any], Dict[str, Any]]] = {}
+
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs, kept, prompts, labels = [], [], {}, {}
+ for j, rec in enumerate(batch):
+ try:
+ video_path = str(rec.get("path") or "")
+ if not os.path.isfile(video_path):
+ print(f"[warn] skip {rec.get('id')}: video not found: {video_path}", flush=True)
+ continue
+ prompt, labs = mmvu_prompt(rec, prompt_mode=args.prompt_mode)
+ video_item = mmvu_video_item(rec, video_path, args)
+ messages = [{"role": "user", "content": [video_item, {"type": "text", "text": prompt}]}]
+ media_key = (video_path, args.video_min_pixels, args.video_max_pixels, args.video_total_pixels, args.fps, args.max_frames)
+ inputs.append(prepare_video_image(messages, processor, media_key, media_cache, enable_thinking=args.enable_thinking))
+ kept.append(j)
+ prompts[j] = prompt
+ labels[j] = labs
+ except Exception as e:
+ print(f"[warn] skip {start + j}: {type(e).__name__}: {e}", flush=True)
+ return start, batch, inputs, kept, prompts, labels
+
+ for start, batch, inputs, kept, prompts, labels in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling) if inputs else []
+ for j, out in zip(kept, outs):
+ rec = batch[j]
+ raw = out.outputs[0].text
+ pred = extract_mcq_answer(raw, labels[j])
+ gt = require_mcq_ground_truth(rec, labels[j], task="mmvu")
+ results.append({
+ "id": str(rec.get("id") or start + j),
+ "bench": "mmvu",
+ "group": rec.get("subject") or "unknown",
+ "question": rec.get("problem") or rec.get("question"),
+ "prompt": prompts[j],
+ "answer": gt,
+ "pred_answer": pred,
+ "raw_prediction": raw,
+ "score": 1.0 if pred and gt and pred == gt else 0.0,
+ })
+ write_json(args.output_dir, f"results_mmvu_shard{args.index}.json", results)
+ write_json(args.output_dir, f"summary_shard{args.index}.json", aggregate_image_mc("mmvu", results))
+
+
+# ---------------------------------------------------------------------------
+# MVBench (multi-task short-video multiple-choice)
+# ---------------------------------------------------------------------------
+
+
+def resolve_relative_video(path_value: Any, data_file: str, video_root: str) -> str:
+ raw = str(path_value or "").strip()
+ if not raw:
+ return ""
+ if os.path.isabs(raw):
+ return raw
+ rel = raw[2:] if raw.startswith("./") else raw
+ candidates = []
+ if video_root:
+ candidates.append(os.path.join(video_root, rel))
+ candidates.append(os.path.join(os.path.dirname(data_file), rel))
+ for cand in candidates:
+ if os.path.isfile(cand):
+ return cand
+ return candidates[0] if candidates else rel
+
+
+def mvbench_prompt(rec: Dict[str, Any], prompt_mode: str = "default") -> Tuple[str, List[str]]:
+ question = str(rec.get("problem") or rec.get("question") or "").strip()
+ options = rec.get("options") or []
+ labels = CHOICES[: len(options)] if options else list("ABCD")
+ if options and "Options:" not in question:
+ question += "\nOptions:\n" + "\n".join(str(x) for x in options)
+ if prompt_mode == "qwen":
+ tail = (
+ "Please answer the question based on the video. "
+ "Put only the choice letter inside tags, e.g., C ."
+ )
+ else:
+ tail = VIDEO_QA_MC_TAIL
+ return f"{question}\n{tail}", labels
+
+
+def worker_mvbench(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = read_json_or_jsonl(args.data_file)
+ records.sort(key=lambda r: (str(r.get("path") or ""), int(r.get("problem_id") or r.get("id") or 0)))
+ if args.max_samples > 0:
+ records = records[: args.max_samples]
+ records = shard_records(records, args.chunk, args.index)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ )
+ sampling_kwargs: Dict[str, Any] = {
+ "max_tokens": args.max_new_tokens,
+ "temperature": args.temperature,
+ "top_p": args.top_p,
+ "top_k": args.top_k,
+ }
+ if args.presence_penalty:
+ sampling_kwargs["presence_penalty"] = args.presence_penalty
+ if args.min_p:
+ sampling_kwargs["min_p"] = args.min_p
+ sampling = SamplingParams(**sampling_kwargs)
+ print(f"[mvbench] sampling={sampling_kwargs} enable_thinking={args.enable_thinking} prompt_mode={args.prompt_mode}", flush=True)
+ results = []
+ media_cache: Dict[Tuple[Any, ...], Tuple[Dict[str, Any], Dict[str, Any]]] = {}
+
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs, kept, prompts, labels = [], [], {}, {}
+ for j, rec in enumerate(batch):
+ try:
+ video_path = resolve_relative_video(rec.get("path") or rec.get("video"), args.data_file, args.preprocessed_video_dir)
+ if not os.path.isfile(video_path):
+ print(f"[warn] skip {rec.get('problem_id') or rec.get('id')}: video not found: {video_path}", flush=True)
+ continue
+ prompt, labs = mvbench_prompt(rec, prompt_mode=args.prompt_mode)
+ video_item = mmvu_video_item(rec, video_path, args)
+ messages = [{"role": "user", "content": [video_item, {"type": "text", "text": prompt}]}]
+ media_key = (video_path, args.video_min_pixels, args.video_max_pixels, args.video_total_pixels, args.fps, args.max_frames)
+ inputs.append(prepare_video_image(messages, processor, media_key, media_cache, enable_thinking=args.enable_thinking))
+ kept.append(j)
+ prompts[j] = prompt
+ labels[j] = labs
+ except Exception as e:
+ print(f"[warn] skip {start + j}: {type(e).__name__}: {e}", flush=True)
+ return start, batch, inputs, kept, prompts, labels
+
+ for start, batch, inputs, kept, prompts, labels in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling) if inputs else []
+ for j, out in zip(kept, outs):
+ rec = batch[j]
+ raw = out.outputs[0].text
+ pred = extract_mcq_answer(raw, labels[j])
+ gt = require_mcq_ground_truth(rec, labels[j], task="mvbench")
+ results.append({
+ "id": str(rec.get("problem_id") or rec.get("id") or start + j),
+ "bench": "mvbench",
+ "group": rec.get("original_question_type") or rec.get("problem_type") or "multiple choice",
+ "question": rec.get("problem") or rec.get("question"),
+ "prompt": prompts[j],
+ "answer": gt,
+ "pred_answer": pred,
+ "raw_prediction": raw,
+ "score": 1.0 if pred and gt and pred == gt else 0.0,
+ })
+ write_json(args.output_dir, f"results_mvbench_shard{args.index}.json", results)
+ write_json(args.output_dir, f"summary_shard{args.index}.json", aggregate_image_mc("mvbench", results))
+
+
+# ---------------------------------------------------------------------------
+# Video-Holmes (complex multi-clue video reasoning, all multiple-choice A-F)
+# ---------------------------------------------------------------------------
+
+# Official Video-Holmes prompt (evaluate.py) is a CoT reasoning prompt.
+VIDEOHOLMES_TASK_TYPES = ["SR", "IMC", "TCI", "TA", "MHR", "PAR", "CTI"]
+
+
+def videoholmes_labels(rec: Dict[str, Any]) -> List[str]:
+ labs = rec.get("labels")
+ if isinstance(labs, list) and labs:
+ return [str(x) for x in labs]
+ options = rec.get("options") or []
+ return CHOICES[: len(options)] if options else list("ABCDEF")
+
+
+def videoholmes_prompt(rec: Dict[str, Any], prompt_mode: str = "default") -> Tuple[str, List[str]]:
+ question = str(rec.get("problem") or rec.get("question") or "").strip()
+ options = rec.get("options") or []
+ labels = videoholmes_labels(rec)
+ opts_inline = ", ".join(str(x).replace(". ", ": ", 1) for x in options)
+ if prompt_mode == "holmes":
+ # Verbatim official CoT prompt (TencentARC/Video-Holmes evaluate.py).
+ prompt = (
+ "Based on the given video, reason and answer the single-choice question. "
+ "Provide your reasoning between the and tags, and then give "
+ "your final answer between the and tags. "
+ f"The question is: {question}. The options are: {opts_inline}. Your answer:"
+ )
+ return prompt, labels
+ # Direct-answer (no-think) default, consistent with our other MC evals.
+ if options and "Options:" not in question:
+ question += "\nOptions:\n" + "\n".join(str(x) for x in options)
+ tail = VIDEO_QA_MC_TAIL
+ return f"{question}\n{tail}", labels
+
+
+def worker_videoholmes(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = read_json_or_jsonl(args.data_file)
+ records.sort(key=lambda r: (str(r.get("path") or ""), str(r.get("id") or "")))
+ if args.max_samples > 0:
+ records = records[: args.max_samples]
+ records = shard_records(records, args.chunk, args.index)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ )
+ sampling_kwargs: Dict[str, Any] = {
+ "max_tokens": args.max_new_tokens,
+ "temperature": args.temperature,
+ "top_p": args.top_p,
+ "top_k": args.top_k,
+ }
+ if args.presence_penalty:
+ sampling_kwargs["presence_penalty"] = args.presence_penalty
+ if args.min_p:
+ sampling_kwargs["min_p"] = args.min_p
+ sampling = SamplingParams(**sampling_kwargs)
+ print(f"[videoholmes] sampling={sampling_kwargs} enable_thinking={args.enable_thinking} prompt_mode={args.prompt_mode}", flush=True)
+ results = []
+ media_cache: Dict[Tuple[Any, ...], Tuple[Dict[str, Any], Dict[str, Any]]] = {}
+
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs, kept, prompts, labels = [], [], {}, {}
+ for j, rec in enumerate(batch):
+ try:
+ video_path = str(rec.get("path") or "")
+ if not os.path.isfile(video_path):
+ print(f"[warn] skip {rec.get('id')}: video not found: {video_path}", flush=True)
+ continue
+ prompt, labs = videoholmes_prompt(rec, prompt_mode=args.prompt_mode)
+ video_item = mmvu_video_item(rec, video_path, args)
+ messages = [{"role": "user", "content": [video_item, {"type": "text", "text": prompt}]}]
+ media_key = (video_path, args.video_min_pixels, args.video_max_pixels, args.video_total_pixels, args.fps, args.max_frames)
+ inputs.append(prepare_video_image(messages, processor, media_key, media_cache, enable_thinking=args.enable_thinking))
+ kept.append(j)
+ prompts[j] = prompt
+ labels[j] = labs
+ except Exception as e:
+ print(f"[warn] skip {start + j}: {type(e).__name__}: {e}", flush=True)
+ return start, batch, inputs, kept, prompts, labels
+
+ for start, batch, inputs, kept, prompts, labels in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling) if inputs else []
+ for j, out in zip(kept, outs):
+ rec = batch[j]
+ raw = out.outputs[0].text
+ pred = extract_mcq_answer(raw, labels[j])
+ gt = require_mcq_ground_truth(rec, labels[j], task="videoholmes")
+ results.append({
+ "id": str(rec.get("id") or start + j),
+ "bench": "videoholmes",
+ "group": rec.get("q_type") or "unknown",
+ "question": rec.get("problem") or rec.get("question"),
+ "prompt": prompts[j],
+ "answer": gt,
+ "pred_answer": pred,
+ "raw_prediction": raw,
+ "score": 1.0 if pred and gt and pred == gt else 0.0,
+ })
+ write_json(args.output_dir, f"results_videoholmes_shard{args.index}.json", results)
+ write_json(args.output_dir, f"summary_shard{args.index}.json", aggregate_image_mc("videoholmes", results))
+
+
+# ---------------------------------------------------------------------------
+# LongVideoBench (long-form video MC, optional subtitles)
+# ---------------------------------------------------------------------------
+
+
+def longvideobench_prompt(
+ rec: Dict[str, Any],
+ prompt_mode: str = "default",
+ use_subtitles: bool = True,
+) -> Tuple[str, List[str]]:
+ question = str(rec.get("problem") or rec.get("question") or "").strip()
+ options = rec.get("options") or []
+ labels = rec.get("labels") if isinstance(rec.get("labels"), list) else CHOICES[: len(options)]
+ subtitle = str(rec.get("subtitle_text") or "").strip() if use_subtitles else ""
+ if use_subtitles and not subtitle:
+ subtitle_path = str(rec.get("subtitle_path") or "").strip()
+ if subtitle_path and os.path.isfile(subtitle_path):
+ subtitle = Path(subtitle_path).read_text(
+ encoding="utf-8",
+ errors="replace",
+ ).strip()
+ if subtitle:
+ question = f"Subtitles/transcript snippets:\n{subtitle}\n\nQuestion: {question}"
+ if options and "Options:" not in question:
+ question += "\nOptions:\n" + "\n".join(str(x) for x in options)
+ if prompt_mode == "qwen":
+ tail = (
+ "Please answer the question based on the video and any provided subtitles. "
+ "Put only the choice letter inside tags, e.g., C ."
+ )
+ else:
+ tail = VIDEO_QA_MC_TAIL
+ return f"{question}\n{tail}", [str(x) for x in labels] if labels else list("ABCDE")
+
+
+def worker_longvideobench(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = read_json_or_jsonl(args.data_file)
+ records.sort(key=lambda r: (str(r.get("path") or ""), str(r.get("id") or "")))
+ if args.max_samples > 0:
+ records = records[: args.max_samples]
+ records = shard_records(records, args.chunk, args.index)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ )
+ sampling_kwargs: Dict[str, Any] = {
+ "max_tokens": args.max_new_tokens,
+ "temperature": args.temperature,
+ "top_p": args.top_p,
+ "top_k": args.top_k,
+ }
+ if args.presence_penalty:
+ sampling_kwargs["presence_penalty"] = args.presence_penalty
+ if args.min_p:
+ sampling_kwargs["min_p"] = args.min_p
+ sampling = SamplingParams(**sampling_kwargs)
+ print(f"[longvideobench] sampling={sampling_kwargs} enable_thinking={args.enable_thinking} prompt_mode={args.prompt_mode}", flush=True)
+ results = []
+ media_cache: Dict[Tuple[Any, ...], Tuple[Dict[str, Any], Dict[str, Any]]] = {}
+
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs, kept, prompts, labels = [], [], {}, {}
+ for j, rec in enumerate(batch):
+ try:
+ video_path = str(rec.get("path") or "")
+ if not os.path.isfile(video_path):
+ print(f"[warn] skip {rec.get('id')}: video not found: {video_path}", flush=True)
+ continue
+ prompt, labs = longvideobench_prompt(
+ rec,
+ prompt_mode=args.prompt_mode,
+ use_subtitles=args.use_subtitles,
+ )
+ video_item = mmvu_video_item(rec, video_path, args)
+ messages = [{"role": "user", "content": [video_item, {"type": "text", "text": prompt}]}]
+ media_key = (video_path, args.video_min_pixels, args.video_max_pixels, args.video_total_pixels, args.fps, args.max_frames)
+ inputs.append(prepare_video_image(messages, processor, media_key, media_cache, enable_thinking=args.enable_thinking))
+ kept.append(j)
+ prompts[j] = prompt
+ labels[j] = labs
+ except Exception as e:
+ print(f"[warn] skip {start + j}: {type(e).__name__}: {e}", flush=True)
+ return start, batch, inputs, kept, prompts, labels
+
+ for start, batch, inputs, kept, prompts, labels in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling) if inputs else []
+ for j, out in zip(kept, outs):
+ rec = batch[j]
+ raw = out.outputs[0].text
+ pred = extract_mcq_answer(raw, labels[j])
+ gt = require_mcq_ground_truth(rec, labels[j], task="longvideobench")
+ results.append({
+ "id": str(rec.get("id") or start + j),
+ "bench": "longvideobench",
+ "group": rec.get("question_category") or "unknown",
+ "topic_category": rec.get("topic_category") or "unknown",
+ "duration_group": rec.get("duration_group"),
+ "question": rec.get("problem") or rec.get("question"),
+ "prompt": prompts[j],
+ "answer": gt,
+ "pred_answer": pred,
+ "raw_prediction": raw,
+ "score": 1.0 if pred and gt and pred == gt else 0.0,
+ })
+ write_json(args.output_dir, f"results_longvideobench_shard{args.index}.json", results)
+ write_json(args.output_dir, f"summary_shard{args.index}.json", aggregate_image_mc("longvideobench", results))
+
+
+# ---------------------------------------------------------------------------
+# LVBench (long-video multiple-choice)
+# ---------------------------------------------------------------------------
+
+
+def lvbench_prompt(rec: Dict[str, Any], prompt_mode: str = "default") -> Tuple[str, List[str]]:
+ question = str(rec.get("problem") or rec.get("question") or "").strip()
+ options = rec.get("options") or []
+ labels = CHOICES[: len(options)] if options else list("ABCD")
+ if options and "Options:" not in question:
+ question += "\nOptions:\n" + "\n".join(str(x) for x in options)
+ if prompt_mode == "qwen":
+ tail = (
+ "Please answer the question based on the video. "
+ "Put only the choice letter inside tags, e.g., C ."
+ )
+ else:
+ tail = VIDEO_QA_MC_TAIL
+ return f"{question}\n{tail}", labels
+
+
+def resolve_lvbench_video(path_value: Any, data_file: str, video_root: str) -> str:
+ raw = str(path_value or "").strip()
+ if not raw:
+ return ""
+ if os.path.isabs(raw):
+ return raw
+ rel = raw[2:] if raw.startswith("./") else raw
+ candidates = []
+ if video_root:
+ candidates.append(os.path.join(video_root, rel))
+ candidates.append(os.path.join(os.path.dirname(data_file), rel))
+ for cand in candidates:
+ if os.path.isfile(cand):
+ return cand
+ return candidates[0] if candidates else rel
+
+
+def worker_lvbench(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = read_json_or_jsonl(args.data_file)
+ records.sort(key=lambda r: (str(r.get("path") or ""), int(r.get("problem_id") or 0)))
+ records = shard_records(records, args.chunk, args.index)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ )
+ sampling_kwargs: Dict[str, Any] = {
+ "max_tokens": args.max_new_tokens,
+ "temperature": args.temperature,
+ "top_p": args.top_p,
+ "top_k": args.top_k,
+ }
+ if args.presence_penalty:
+ sampling_kwargs["presence_penalty"] = args.presence_penalty
+ if args.min_p:
+ sampling_kwargs["min_p"] = args.min_p
+ sampling = SamplingParams(**sampling_kwargs)
+ print(f"[lvbench] sampling={sampling_kwargs} enable_thinking={args.enable_thinking} prompt_mode={args.prompt_mode}", flush=True)
+ results = []
+ media_cache: Dict[Tuple[Any, ...], Tuple[Dict[str, Any], Dict[str, Any]]] = {}
+
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs, kept, prompts, labels = [], [], {}, {}
+ for j, rec in enumerate(batch):
+ try:
+ video_path = resolve_lvbench_video(rec.get("path"), args.data_file, args.preprocessed_video_dir)
+ if not os.path.isfile(video_path):
+ print(f"[warn] skip {rec.get('problem_id')}: video not found: {video_path}", flush=True)
+ continue
+ prompt, labs = lvbench_prompt(rec, prompt_mode=args.prompt_mode)
+ video_item = mmvu_video_item(rec, video_path, args)
+ messages = [{"role": "user", "content": [video_item, {"type": "text", "text": prompt}]}]
+ media_key = (video_path, args.video_min_pixels, args.video_max_pixels, args.video_total_pixels, args.fps, args.max_frames)
+ inputs.append(prepare_video_image(messages, processor, media_key, media_cache, enable_thinking=args.enable_thinking))
+ kept.append(j)
+ prompts[j] = prompt
+ labels[j] = labs
+ except Exception as e:
+ print(f"[warn] skip {start + j}: {type(e).__name__}: {e}", flush=True)
+ return start, batch, inputs, kept, prompts, labels
+
+ for start, batch, inputs, kept, prompts, labels in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling) if inputs else []
+ for j, out in zip(kept, outs):
+ rec = batch[j]
+ raw = out.outputs[0].text
+ pred = extract_mcq_answer(raw, labels[j])
+ gt = require_mcq_ground_truth(rec, labels[j], task="lvbench")
+ results.append({
+ "id": str(rec.get("problem_id") or rec.get("id") or start + j),
+ "bench": "lvbench",
+ "group": rec.get("problem_type") or "multiple choice",
+ "data_source": rec.get("data_source") or "LVBench",
+ "question": rec.get("problem") or rec.get("question"),
+ "prompt": prompts[j],
+ "answer": gt,
+ "pred_answer": pred,
+ "raw_prediction": raw,
+ "score": 1.0 if pred and gt and pred == gt else 0.0,
+ })
+ write_json(args.output_dir, f"results_lvbench_shard{args.index}.json", results)
+ write_json(args.output_dir, f"summary_shard{args.index}.json", aggregate_image_mc("lvbench", results))
+
+
+# ---------------------------------------------------------------------------
+# MLVU (dev, multi-task long video understanding, multiple-choice M-Avg)
+# ---------------------------------------------------------------------------
+
+
+def mlvu_prompt(rec: Dict[str, Any], prompt_mode: str = "default") -> Tuple[str, List[str]]:
+ question = str(rec.get("problem") or rec.get("question") or "").strip()
+ options = rec.get("options") or []
+ labels = rec.get("labels") if isinstance(rec.get("labels"), list) else CHOICES[: len(options)]
+ if options and "Options:" not in question:
+ question += "\nOptions:\n" + "\n".join(str(x) for x in options)
+ if prompt_mode == "qwen":
+ tail = (
+ "Please answer the question based on the visual content. "
+ "Put only the choice letter inside tags, e.g., C ."
+ )
+ else:
+ tail = VIDEO_QA_MC_TAIL
+ return f"{question}\n{tail}", [str(x) for x in labels] if labels else list("ABCD")
+
+
+def worker_mlvu(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = read_json_or_jsonl(args.data_file)
+ records.sort(key=lambda r: (str(r.get("path") or ""), str(r.get("id") or "")))
+ if args.max_samples > 0:
+ records = records[: args.max_samples]
+ records = shard_records(records, args.chunk, args.index)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ )
+ sampling_kwargs: Dict[str, Any] = {
+ "max_tokens": args.max_new_tokens,
+ "temperature": args.temperature,
+ "top_p": args.top_p,
+ "top_k": args.top_k,
+ }
+ if args.presence_penalty:
+ sampling_kwargs["presence_penalty"] = args.presence_penalty
+ if args.min_p:
+ sampling_kwargs["min_p"] = args.min_p
+ sampling = SamplingParams(**sampling_kwargs)
+ print(f"[mlvu] sampling={sampling_kwargs} enable_thinking={args.enable_thinking} prompt_mode={args.prompt_mode}", flush=True)
+ results = []
+ media_cache: Dict[Tuple[Any, ...], Tuple[Dict[str, Any], Dict[str, Any]]] = {}
+
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs, kept, prompts, labels = [], [], {}, {}
+ for j, rec in enumerate(batch):
+ try:
+ video_path = str(rec.get("path") or "")
+ if not os.path.isfile(video_path):
+ print(f"[warn] skip {rec.get('id')}: video not found: {video_path}", flush=True)
+ continue
+ prompt, labs = mlvu_prompt(rec, prompt_mode=args.prompt_mode)
+ video_item = mmvu_video_item(rec, video_path, args)
+ messages = [{"role": "user", "content": [video_item, {"type": "text", "text": prompt}]}]
+ media_key = (video_path, args.video_min_pixels, args.video_max_pixels, args.video_total_pixels, args.fps, args.max_frames)
+ inputs.append(prepare_video_image(messages, processor, media_key, media_cache, enable_thinking=args.enable_thinking))
+ kept.append(j)
+ prompts[j] = prompt
+ labels[j] = labs
+ except Exception as e:
+ print(f"[warn] skip {start + j}: {type(e).__name__}: {e}", flush=True)
+ return start, batch, inputs, kept, prompts, labels
+
+ for start, batch, inputs, kept, prompts, labels in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling) if inputs else []
+ for j, out in zip(kept, outs):
+ rec = batch[j]
+ raw = out.outputs[0].text
+ pred = extract_mcq_answer(raw, labels[j])
+ gt = require_mcq_ground_truth(rec, labels[j], task="mlvu")
+ results.append({
+ "id": str(rec.get("id") or start + j),
+ "bench": "mlvu",
+ "group": rec.get("task_type") or "unknown",
+ "question": rec.get("problem") or rec.get("question"),
+ "prompt": prompts[j],
+ "answer": gt,
+ "pred_answer": pred,
+ "raw_prediction": raw,
+ "score": 1.0 if pred and gt and pred == gt else 0.0,
+ })
+ write_json(args.output_dir, f"results_mlvu_shard{args.index}.json", results)
+ write_json(args.output_dir, f"summary_shard{args.index}.json", aggregate_image_mc("mlvu", results))
+
+
+# ---------------------------------------------------------------------------
+# VSI
+# ---------------------------------------------------------------------------
+
+
+NUMERICAL_TASKS = {"object_abs_distance", "object_counting", "object_size_estimation", "room_size_estimation"}
+VSI_REPORT = [
+ ("Obj. Count", "object_counting", "MRA"),
+ ("Abs. Dist", "object_abs_distance", "MRA"),
+ ("Obj. Size", "object_size_estimation", "MRA"),
+ ("Room Size", "room_size_estimation", "MRA"),
+ ("Rel. Dis", "object_rel_distance", "ACC"),
+ ("Rel. Dir", "object_rel_direction", "ACC"),
+ ("Route Plan", "route_planning", "ACC"),
+ ("Appr. Order", "obj_appearance_order", "ACC"),
+]
+
+
+def vsi_qtype(rec: Dict[str, Any]) -> str:
+ return str(
+ rec.get("original_question_type")
+ or rec.get("question_type")
+ or rec.get("problem_type")
+ or "unknown"
+ )
+
+
+def vsi_options(options: Any) -> str:
+ if not options:
+ return ""
+ if isinstance(options, dict):
+ values = [str(options[k]) for k in sorted(options)]
+ elif isinstance(options, (list, tuple)):
+ values = [str(x) for x in options]
+ else:
+ values = [str(options)]
+ return "\n".join(v.strip() for v in values if v.strip())
+
+
+def vsi_prompt(rec: Dict[str, Any]) -> str:
+ qtype = vsi_qtype(rec)
+ prompt = str(rec.get("prompt") or rec.get("question") or rec.get("problem") or "").strip()
+ prompt = re.sub(r"^(?:\s*\s*|\s*\s*)+", "", prompt).strip()
+ options = vsi_options(rec.get("options"))
+ if options and "Options:" not in prompt:
+ prompt += "\nOptions:\n" + options
+ if qtype == "object_counting" and "Answer with an integer within " not in prompt:
+ prompt += " " + VSI_INTEGER_TAIL
+ elif qtype == "object_abs_distance" and "Answer with a number in meters within " not in prompt:
+ prompt += " " + VSI_METERS_TAIL
+ elif qtype == "object_size_estimation":
+ prompt = re.sub(r"What is the length of the longest dimension \(length, width, or height\) of ([^,?]+), measured in centimeters\?",
+ r"What is the longest dimension (length, width, or height) of \1 in centimeters?", prompt)
+ if "Answer with a number in centimeters within " not in prompt:
+ prompt += " " + VSI_CENTIMETERS_TAIL
+ elif qtype == "room_size_estimation":
+ prompt = "What is the size of this room in square meters? If multiple rooms are shown, estimate the combined size. " + VSI_SQUARE_METERS_TAIL
+ else:
+ if qtype == "object_rel_distance" and "If there are multiple instances" not in prompt:
+ prompt = prompt.replace("\nOptions:", "\nIf there are multiple instances of an object category, measure to the closest.\nOptions:")
+ prompt = LEGACY_MC_TAIL_RE.sub("", prompt).rstrip()
+ # spatial_intelligence MC uses "Answer with the option letter within ..."
+ # (no "only"), matching data/joint/sft_joint_all.jsonl.
+ if "Answer with the option letter within " not in prompt:
+ prompt += "\n" + VSI_MC_TAIL
+ if not prompt.startswith("These are frames of a video."):
+ prompt = "These are frames of a video.\n" + prompt
+ return prompt
+
+
+def vsi_preproc_path(rec: Dict[str, Any], root: str) -> str:
+ value = rec.get("preprocessed_video") or rec.get("preprocessed_video_path")
+ if not value:
+ return ""
+ return value if os.path.isabs(str(value)) else os.path.join(root, str(value))
+
+
+def to_float(value: Any) -> Optional[float]:
+ try:
+ return float(str(value).strip())
+ except Exception:
+ return None
+
+
+def mean_relative_accuracy(pred: float, target: float) -> float:
+ if target == 0:
+ return 0.0
+ confs = np.linspace(0.5, 0.95, int((0.95 - 0.5) / 0.05 + 2))
+ return float((abs(pred - target) / abs(target) <= 1 - confs).mean())
+
+
+def extract_number(text: str) -> str:
+ text = strip_answer_tags(strip_think_block(text))
+ m = re.search(r"[-+]?\d+(?:\.\d+)?", text)
+ return m.group(0) if m else ""
+
+
+def vsi_score(qtype: str, pred: str, gt: str) -> Tuple[str, str, float]:
+ if qtype in NUMERICAL_TASKS:
+ pv, gv = to_float(extract_number(pred)), to_float(strip_answer_tags(gt))
+ if pv is None or gv is None:
+ return "MRA", "", 0.0
+ return "MRA", str(pv), mean_relative_accuracy(pv, gv)
+ p = extract_mcq_answer(pred, list("ABCD"))
+ g = extract_mcq_answer(gt, list("ABCD"))
+ return "ACC", p, 1.0 if p and g and p == g else 0.0
+
+
+def worker_vsi(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = read_json_or_jsonl(args.data_file)
+ records.sort(key=lambda r: str(r.get("preprocessed_video") or r.get("preprocessed_video_path") or r.get("path") or r.get("scene_name") or ""))
+ records = shard_records(records, args.chunk, args.index)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", do_resize=False, trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ patch_size = getattr(processor.image_processor, "patch_size", 16)
+ llm = LLM(model=args.model_path, tensor_parallel_size=args.tp_size, gpu_memory_utilization=args.gpu_memory_utilization, max_model_len=args.max_model_len, trust_remote_code=True, limit_mm_per_prompt={"video": 1, "image": 1})
+ sampling = SamplingParams(max_tokens=args.max_new_tokens, temperature=0.0, top_p=1.0)
+ out_path = Path(args.output_dir) / f"results_shard{args.index}.jsonl"
+ with out_path.open("w", encoding="utf-8") as f:
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs = []
+ for rec in batch:
+ preprocessed_path = vsi_preproc_path(
+ rec,
+ args.preprocessed_video_dir,
+ )
+ use_preprocessed = bool(
+ preprocessed_path and os.path.isfile(preprocessed_path)
+ )
+ path = (
+ preprocessed_path
+ if use_preprocessed
+ else str(
+ rec.get("video_path")
+ or rec.get("video")
+ or rec.get("path")
+ or ""
+ )
+ )
+ video_item: Dict[str, Any] = {
+ "type": "video",
+ "video": path,
+ "min_pixels": args.video_min_pixels,
+ "max_pixels": args.video_max_pixels,
+ "max_frames": args.max_frames,
+ "fps": args.fps,
+ }
+ if args.video_total_pixels > 0:
+ video_item["total_pixels"] = args.video_total_pixels
+ messages = [
+ {"role": "system", "content": "You are a helpful assistant."},
+ {
+ "role": "user",
+ "content": [
+ video_item,
+ {"type": "text", "text": vsi_prompt(rec)},
+ ],
+ },
+ ]
+ inputs.append(
+ prepare_preprocessed_video(
+ messages,
+ processor,
+ path,
+ )
+ if use_preprocessed
+ else prepare_raw_video(messages, processor, patch_size)
+ )
+ return start, batch, inputs
+
+ for start, batch, inputs in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling)
+ for rec, out in zip(batch, outs):
+ raw = out.outputs[0].text
+ qtype = vsi_qtype(rec)
+ metric, clean, score = vsi_score(qtype, raw, str(rec.get("solution") or rec.get("ground_truth") or rec.get("answer") or ""))
+ row = {
+ "id": str(rec.get("problem_id") or rec.get("id") or start),
+ "dataset": rec.get("dataset") or rec.get("data_source") or "vsi",
+ "scene_name": rec.get("path") or rec.get("scene_name"),
+ "question_type": qtype,
+ "question": rec.get("question") or rec.get("problem"),
+ "prompt": vsi_prompt(rec),
+ "pred": raw,
+ "pred_clean": clean,
+ "ground_truth": strip_answer_tags(str(rec.get("solution") or rec.get("ground_truth") or rec.get("answer") or "")),
+ "options": rec.get("options"),
+ "preprocessed_video_path": vsi_preproc_path(rec, args.preprocessed_video_dir),
+ "media_mode": "video",
+ "score": score,
+ "metric": metric,
+ }
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
+
+
+def vsi_report_key(qtype: str) -> str:
+ return "object_rel_direction" if str(qtype).startswith("object_rel_direction") else str(qtype)
+
+
+def aggregate_vsi(records: List[Dict[str, Any]]) -> Dict[str, Any]:
+ scores = defaultdict(list)
+ qtype_scores = defaultdict(list)
+ for r in records:
+ q = r.get("question_type", "unknown")
+ s = float(r.get("score", 0.0) or 0.0)
+ scores[vsi_report_key(q)].append(s)
+ qtype_scores[q].append(s)
+ task_scores = {}
+ for display, key, metric in VSI_REPORT:
+ vals = scores.get(key, [])
+ if vals:
+ task_scores[key] = {"display": display, "metric": metric, "score": round(float(np.mean(vals) * 100), 2), "count": len(vals)}
+ num_vals = [v["score"] for k, v in task_scores.items() if k in NUMERICAL_TASKS]
+ mc_keys = {"object_rel_distance", "object_rel_direction", "route_planning", "obj_appearance_order"}
+ mc_vals = [v["score"] for k, v in task_scores.items() if k in mc_keys]
+ all_vals = [v["score"] for v in task_scores.values()]
+ summary = {"total": len(records), "task_scores": task_scores, "question_type_scores": {k: {"score": round(float(np.mean(v) * 100), 2), "count": len(v)} for k, v in sorted(qtype_scores.items())}}
+ if num_vals:
+ summary["numerical_avg"] = round(float(np.mean(num_vals)), 2)
+ if mc_vals:
+ summary["mc_avg"] = round(float(np.mean(mc_vals)), 2)
+ if all_vals:
+ summary["overall_avg"] = round(float(np.mean(all_vals)), 2)
+ return summary
+
+
+# ---------------------------------------------------------------------------
+# Image-sequence MC: MMSI + MindCube
+# ---------------------------------------------------------------------------
+
+
+def load_image_records(args) -> List[Dict[str, Any]]:
+ if args.task == "mmsi":
+ if args.data_file.endswith((".jsonl", ".json")):
+ return read_json_or_jsonl(args.data_file)
+ csv.field_size_limit(sys.maxsize)
+ with open(args.data_file, encoding="utf-8", errors="replace", newline="") as f:
+ return list(csv.DictReader(f, delimiter="\t"))
+ return load_mindcube_records(
+ args.data_file,
+ chunk=args.chunk,
+ index=args.index,
+ expected_samples=args.expected_samples,
+ cache_dir=os.environ.get("MINDCUBE_HF_CACHE_DIR") or None,
+ )
+
+
+def decode_mmsi_images(value: Any, max_images: int) -> List[Image.Image]:
+ images_b64 = ast.literal_eval(value) if isinstance(value, str) else value
+ if not isinstance(images_b64, (list, tuple)):
+ images_b64 = [images_b64]
+ images = []
+ for item in images_b64[:max_images]:
+ if not item:
+ continue
+ path = ""
+ if isinstance(item, dict):
+ path = str(
+ item.get("path")
+ or item.get("image")
+ or item.get("image_path")
+ or ""
+ )
+ elif isinstance(item, str) and os.path.isfile(item):
+ path = item
+ if path:
+ images.append(Image.open(path).convert("RGB"))
+ continue
+ raw = item if isinstance(item, bytes) else base64.b64decode(str(item))
+ images.append(Image.open(io.BytesIO(raw)).convert("RGB"))
+ return images
+
+
+def normalize_choices(value: Any) -> List[str]:
+ if value is None:
+ return []
+ if hasattr(value, "tolist"):
+ value = value.tolist()
+ if isinstance(value, (list, tuple)):
+ return [str(v) for v in value]
+ return [str(value)]
+
+
+def labels_for(choices: List[Any]) -> List[str]:
+ return CHOICES[:len(choices)] if choices else list("ABCD")
+
+
+def image_prompt(args, rec: Dict[str, Any]) -> Tuple[str, List[str]]:
+ if args.task == "mmsi":
+ q = str(rec.get("question") or "").strip()
+ labels = []
+ for ch in re.findall(r"(?:^|[\s,])([A-H])\s*[:\).]", q):
+ if ch not in labels:
+ labels.append(ch)
+ return f"{q}\n{PROMPT_TAIL}", labels or list("ABCD")
+ return mindcube_prompt(rec, PROMPT_TAIL)
+
+
+def image_answer(args, rec: Dict[str, Any]) -> str:
+ answer = (
+ mindcube_answer_value(rec)
+ if args.task == "mindcube"
+ else rec.get("answer")
+ )
+ if args.task == "mindcube" and isinstance(answer, (int, float)) and not isinstance(answer, bool):
+ labels = labels_for(normalize_choices(rec.get("choices")))
+ idx = int(answer)
+ return labels[idx] if 0 <= idx < len(labels) else ""
+ return extract_mcq_answer(str(answer), CHOICES) or str(answer).strip().upper()[:1]
+
+
+def prepare_images(images: List[Image.Image], prompt: str, processor, args) -> Dict[str, Any]:
+ from qwen_vl_utils import process_vision_info
+
+ content = []
+ for img in images:
+ item = {"type": "image", "image": img, "max_pixels": 262144}
+ item["min_pixels"] = 4096
+ content.append(item)
+ content.append({"type": "text", "text": prompt})
+ messages = [{"role": "user", "content": content}]
+ text = render_chat_prompt(messages, processor, False)
+ image_inputs, _ = process_vision_info(messages, image_patch_size=16)
+ return {"prompt": text, "multi_modal_data": {"image": image_inputs}, "mm_processor_kwargs": {"do_resize": False}}
+
+
+def worker_image_mc(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ records = load_image_records(args)
+ if args.task == "mmsi":
+ records = shard_records(records, args.chunk, args.index)
+ processor = AutoProcessor.from_pretrained(args.model_path, padding_side="left", trust_remote_code=True, min_pixels=4096, max_pixels=262144)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ trust_remote_code=True,
+ limit_mm_per_prompt={"image": args.max_images},
+ )
+ sampling = SamplingParams(max_tokens=args.max_new_tokens, temperature=0.0, top_p=1.0, top_k=-1)
+ results = []
+
+ def prepare_batch(start: int, batch: List[Dict[str, Any]]):
+ inputs, kept, prompts, labels = [], [], {}, {}
+ for j, rec in enumerate(batch):
+ try:
+ images = (
+ decode_mmsi_images(
+ rec.get("images") or rec.get("image"),
+ args.max_images,
+ )
+ if args.task == "mmsi"
+ else decode_mindcube_images(rec, args.max_images, 1024)
+ )
+ if not images:
+ continue
+ prompt, labs = image_prompt(args, rec)
+ inputs.append(prepare_images(images, prompt, processor, args))
+ kept.append(j)
+ prompts[j] = prompt
+ labels[j] = labs
+ except Exception as e:
+ print(f"[warn] skip {start + j}: {type(e).__name__}: {e}", flush=True)
+ return start, batch, inputs, kept, prompts, labels
+
+ for start, batch, inputs, kept, prompts, labels in iter_prefetched_batches(records, args.batch_size, prepare_batch, args.prefetch_batches):
+ outs = llm.generate(inputs, sampling_params=sampling) if inputs else []
+ for j, out in zip(kept, outs):
+ rec = batch[j]
+ raw = out.outputs[0].text
+ pred = extract_mcq_answer(raw, labels[j])
+ gt = image_answer(args, rec)
+ group = (
+ rec.get("category")
+ if args.task == "mmsi"
+ else mindcube_group(rec)
+ )
+ results.append({"id": str(rec.get("index") or rec.get("id") or start + j), "bench": args.task, "group": group or "unknown", "question": rec.get("question"), "prompt": prompts[j], "answer": gt, "pred_answer": pred, "raw_prediction": raw, "score": 1.0 if pred and gt and pred == gt else 0.0})
+ write_json(args.output_dir, f"results_{args.task}_shard{args.index}.json", results)
+ write_json(args.output_dir, f"summary_shard{args.index}.json", aggregate_image_mc(args.task, results))
+
+
+def aggregate_image_mc(task: str, samples: List[Dict[str, Any]]) -> Dict[str, Any]:
+ groups = defaultdict(lambda: {"correct": 0, "total": 0})
+ for s in samples:
+ groups[str(s.get("group") or "unknown")]["total"] += 1
+ groups[str(s.get("group") or "unknown")]["correct"] += int(s.get("score", 0))
+ correct = sum(int(s.get("score", 0)) for s in samples)
+ parsed = sum(1 for s in samples if s.get("pred_answer"))
+ labeled = sum(1 for s in samples if s.get("answer"))
+ key = "by_category" if task == "mmsi" else (
+ "by_subject" if task == "mmvu" else (
+ "by_task_type" if task == "mlvu" else (
+ "by_question_type" if task in {"videoholmes", "longvideobench"} else "by_task"
+ )
+ )
+ )
+ by_group = {k: {"accuracy": pct(v["correct"], v["total"]), "correct": v["correct"], "total": v["total"]} for k, v in sorted(groups.items())}
+ out = {
+ "num_samples": len(samples),
+ "correct": correct,
+ "accuracy": pct(correct, len(samples)),
+ "parse_rate": pct(parsed, len(samples)),
+ "ground_truth_rate": pct(labeled, len(samples)),
+ key: by_group,
+ }
+ if task == "mlvu" and by_group:
+ # Official MLVU M-Avg is the macro mean over the per-task accuracies.
+ out["M_avg"] = round(sum(g["accuracy"] for g in by_group.values()) / len(by_group), 2)
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Spatial grounding
+# ---------------------------------------------------------------------------
+
+
+def aggregate_spatial_grounding(samples: List[Dict[str, Any]]) -> Dict[str, Any]:
+ n = len(samples)
+ ious = [float(s.get("iou", 0.0) or 0.0) for s in samples]
+ parsed = sum(1 for s in samples if s.get("pred_bbox") is not None)
+ metrics = {
+ "num_samples": n,
+ "mIoU": round(sum(ious) / max(n, 1) * 100, 2),
+ "parse_rate": round(parsed / max(n, 1) * 100, 2),
+ }
+ for threshold in (0.5, 0.7, 0.9):
+ metrics[f"acc@{threshold}"] = round(sum(1 for v in ious if v >= threshold) / max(n, 1) * 100, 2)
+ return metrics
+
+
+# ---------------------------------------------------------------------------
+# Tracking
+# ---------------------------------------------------------------------------
+
+
+def worker_tracking(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ from eval.task.tracking.eval_tracking_vllm import evaluate_one
+
+ if not args.processor_path:
+ args.processor_path = args.model_path
+
+ processor = AutoProcessor.from_pretrained(
+ args.processor_path,
+ padding_side="left",
+ do_resize=False,
+ trust_remote_code=True,
+ )
+ tokenizer = AutoTokenizer.from_pretrained(args.processor_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ args.patch_size = processor.image_processor.patch_size
+
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ enforce_eager=args.enforce_eager,
+ enable_chunked_prefill=True,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ seed=args.seed,
+ )
+ sampling = SamplingParams(
+ max_tokens=args.max_new_tokens,
+ temperature=0.0,
+ top_p=1.0,
+ stop_token_ids=[],
+ )
+
+ summaries = {}
+ for dataset in [s.strip() for s in args.datasets.split(",") if s.strip()]:
+ metrics = evaluate_one(llm, sampling, processor, dataset, args)
+ if metrics:
+ summaries[dataset] = metrics
+ suffix = f"_shard{args.index}" if args.chunk > 1 else ""
+ write_json(args.output_dir, f"summary{suffix}.json", summaries)
+
+
+def aggregate_tracking(samples: List[Dict[str, Any]]) -> Dict[str, Any]:
+ n = len(samples)
+ mious = [float(s.get("miou", 0.0) or 0.0) for s in samples]
+ all_frame_ious = []
+ for sample in samples:
+ values = sample.get("frame_ious")
+ if isinstance(values, list):
+ all_frame_ious.extend(float(v) for v in values)
+ nf = len(all_frame_ious)
+ parsed = sum(1 for s in samples if int(s.get("pred_boxes_n", 0) or 0) > 0)
+ return {
+ "num_samples": n,
+ "num_frames": nf,
+ "AO": round(sum(all_frame_ious) / max(nf, 1) * 100, 2),
+ "R@0.3": round(sum(1 for v in all_frame_ious if v >= 0.3) / max(nf, 1) * 100, 2),
+ "R@0.5": round(sum(1 for v in all_frame_ious if v >= 0.5) / max(nf, 1) * 100, 2),
+ "R@0.7": round(sum(1 for v in all_frame_ious if v >= 0.7) / max(nf, 1) * 100, 2),
+ "mIoU": round(sum(mious) / max(n, 1) * 100, 2),
+ "sample_R@0.3": round(sum(1 for v in mious if v >= 0.3) / max(n, 1) * 100, 2),
+ "sample_R@0.5": round(sum(1 for v in mious if v >= 0.5) / max(n, 1) * 100, 2),
+ "sample_R@0.7": round(sum(1 for v in mious if v >= 0.7) / max(n, 1) * 100, 2),
+ "parse_rate": round(parsed / max(n, 1) * 100, 2),
+ }
+
+
+def worker_stvg(args) -> None:
+ from transformers import AutoProcessor, AutoTokenizer
+ from vllm import LLM, SamplingParams
+
+ from eval.task.spatial_temporal_grounding.eval_stvg_vllm import evaluate_one
+
+ if not args.processor_path:
+ args.processor_path = args.model_path
+
+ processor = AutoProcessor.from_pretrained(
+ args.processor_path,
+ padding_side="left",
+ do_resize=False,
+ trust_remote_code=True,
+ )
+ tokenizer = AutoTokenizer.from_pretrained(args.processor_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ args.patch_size = processor.image_processor.patch_size
+
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tp_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ enforce_eager=args.enforce_eager,
+ enable_chunked_prefill=True,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ seed=args.seed,
+ )
+ sampling = SamplingParams(
+ max_tokens=args.max_new_tokens,
+ temperature=0.0,
+ top_p=1.0,
+ stop_token_ids=[],
+ )
+
+ summaries = {}
+ for dataset in [s.strip() for s in args.datasets.split(",") if s.strip()]:
+ metrics = evaluate_one(llm, sampling, processor, dataset, args)
+ if metrics:
+ summaries[dataset] = metrics
+ suffix = f"_shard{args.index}" if args.chunk > 1 else ""
+ write_json(args.output_dir, f"summary{suffix}.json", summaries)
+
+
+def aggregate_stvg(samples: List[Dict[str, Any]]) -> Dict[str, Any]:
+ n = len(samples)
+ accs = [float(s.get("accuracy", 0.0) or 0.0) for s in samples]
+ tious = [float(s.get("tiou", 0.0) or 0.0) for s in samples]
+ sious = [float(s.get("siou", 0.0) or 0.0) for s in samples]
+ mious_inter = [float(s.get("miou_inter", 0.0) or 0.0) for s in samples]
+ parsed = sum(1 for s in samples if s.get("pred_time") is not None or int(s.get("pred_boxes_n", 0) or 0) > 0)
+ metrics = {
+ "num_samples": n,
+ "tIoU": round(sum(tious) / max(n, 1) * 100, 2),
+ "tIoU@0.5": round(sum(1 for v in tious if v >= 0.5) / max(n, 1) * 100, 2),
+ "sIoU": round(sum(sious) / max(n, 1) * 100, 2),
+ "sIoU@0.5": round(sum(1 for v in sious if v >= 0.5) / max(n, 1) * 100, 2),
+ "mIoU_inter": round(sum(mious_inter) / max(n, 1) * 100, 2),
+ "mean_accuracy": round(sum(accs) / max(n, 1) * 100, 2),
+ "parse_rate": round(parsed / max(n, 1) * 100, 2),
+ }
+ for threshold in (0.3, 0.7):
+ metrics[f"tIoU@{threshold}"] = round(sum(1 for v in tious if v >= threshold) / max(n, 1) * 100, 2)
+ metrics[f"sIoU@{threshold}"] = round(sum(1 for v in sious if v >= threshold) / max(n, 1) * 100, 2)
+ return metrics
+
+
+# ---------------------------------------------------------------------------
+# Parent orchestration / merge
+# ---------------------------------------------------------------------------
+
+
+def write_json(output_dir: str | Path, name: str, obj: Any) -> None:
+ path = Path(output_dir) / name
+ path.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def load_json_files(paths: Iterable[Path]) -> List[Dict[str, Any]]:
+ rows = []
+ for p in paths:
+ if p.is_file():
+ rows.extend(json.loads(p.read_text(encoding="utf-8")))
+ return rows
+
+
+def dataset_stem(name: str) -> str:
+ base = name[:-5] if name.endswith(".json") else name
+ return base[:-6] if base.endswith(".jsonl") else base
+
+
+def merge_task(task: str, output_dir: Path, n: int) -> None:
+ if task in {"videomme", "videommev2"}:
+ rows = load_json_files(output_dir / f"results_{task}_shard{i}.json" for i in range(n))
+ write_json(output_dir, f"results_{task}.json", rows)
+ write_json(output_dir, "summary.json", aggregate_videomme(rows))
+ elif task == "vsi":
+ seen = {}
+ for i in range(n):
+ p = output_dir / f"results_shard{i}.jsonl"
+ if p.is_file():
+ for line in p.read_text(encoding="utf-8").splitlines():
+ if line.strip():
+ r = json.loads(line)
+ seen[str(r.get("id"))] = r
+ rows = list(seen.values())
+ expected_samples = env_int("VSI_EXPECTED_SAMPLES", 5130)
+ if expected_samples > 0 and len(rows) != expected_samples:
+ raise RuntimeError(
+ f"Expected {expected_samples} VSI-Bench results, got {len(rows)}."
+ )
+ (output_dir / "merged_results.jsonl").write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows), encoding="utf-8")
+ summary = aggregate_vsi(rows)
+ if expected_samples > 0:
+ summary["expected_samples"] = expected_samples
+ summary["coverage"] = round(100.0 * len(rows) / expected_samples, 2)
+ write_json(output_dir, "summary.json", summary)
+ elif task == "spatial_grounding":
+ summary = {}
+ default_datasets = "refcoco-val,refcoco-testA,refcoco-testB,refcoco+-val,refcoco+-testA,refcoco+-testB,refcocog-val,refcocog-test"
+ datasets = [x.strip() for x in os.environ.get("SPATIAL_GROUNDING_DATASETS", default_datasets).split(",") if x.strip()]
+ for dataset in datasets:
+ name = dataset_stem(dataset)
+ rows = load_json_files(output_dir / f"results_{name}_shard{i}.json" for i in range(n))
+ if rows:
+ write_json(output_dir, f"results_{name}.json", rows)
+ summary[name] = aggregate_spatial_grounding(rows)
+ else:
+ single = output_dir / f"results_{name}.json"
+ if single.is_file():
+ rows = json.loads(single.read_text(encoding="utf-8"))
+ summary[name] = aggregate_spatial_grounding(rows)
+ write_json(output_dir, "summary.json", summary)
+ elif task == "tracking":
+ summary = {}
+ datasets = [x.strip() for x in os.environ.get("TRACKING_DATASETS", "eval_got10k").split(",") if x.strip()]
+ for dataset in datasets:
+ name = dataset_stem(dataset)
+ rows = load_json_files(output_dir / f"results_{name}_shard{i}.json" for i in range(n))
+ if rows:
+ write_json(output_dir, f"results_{name}.json", rows)
+ summary[name] = aggregate_tracking(rows)
+ else:
+ single = output_dir / f"results_{name}.json"
+ if single.is_file():
+ rows = json.loads(single.read_text(encoding="utf-8"))
+ summary[name] = aggregate_tracking(rows)
+ write_json(output_dir, "summary.json", summary)
+ elif task == "stvg":
+ summary = {}
+ datasets = [x.strip() for x in os.environ.get("STVG_DATASETS", "eval_stvg").split(",") if x.strip()]
+ for dataset in datasets:
+ name = dataset_stem(dataset)
+ rows = load_json_files(output_dir / f"results_{name}_shard{i}.json" for i in range(n))
+ if rows:
+ write_json(output_dir, f"results_{name}.json", rows)
+ summary[name] = aggregate_stvg(rows)
+ else:
+ single = output_dir / f"results_{name}.json"
+ if single.is_file():
+ rows = json.loads(single.read_text(encoding="utf-8"))
+ summary[name] = aggregate_stvg(rows)
+ write_json(output_dir, "summary.json", summary)
+ else:
+ rows = load_json_files(output_dir / f"results_{task}_shard{i}.json" for i in range(n))
+ expected_samples = (
+ env_int("MINDCUBE_EXPECTED_SAMPLES", 1050)
+ if task == "mindcube"
+ else 0
+ )
+ if expected_samples > 0 and len(rows) != expected_samples:
+ raise RuntimeError(
+ f"Expected {expected_samples} MindCube-Tiny results, "
+ f"got {len(rows)}."
+ )
+ write_json(output_dir, f"results_{task}.json", rows)
+ summary = aggregate_image_mc(task, rows)
+ if expected_samples > 0:
+ summary["expected_samples"] = expected_samples
+ summary["coverage"] = round(
+ 100.0 * len(rows) / expected_samples, 2
+ )
+ if task == "mindcube":
+ summary["data_source"] = os.environ.get(
+ "MINDCUBE_DATA_FILE", ""
+ )
+ write_json(output_dir, "summary.json", summary)
+ print(f"Summary -> {output_dir / 'summary.json'}", flush=True)
+
+
+def launch_task(task: str, model: str, gpu_groups: List[str], args) -> None:
+ d = task_defaults(task)
+ out = make_output_dir(task, model, d["setting"])
+ cmds = []
+ for i in range(len(gpu_groups)):
+ cmd = [PYTHON, str(THIS_FILE), "--worker", "--task", task, "--model_path", model, "--output_dir", str(out), "--chunk", str(len(gpu_groups)), "--index", str(i), "--tp_size", str(args.tp_size), "--data_file", d["data_file"], "--batch_size", str(d["batch_size"]), "--prefetch_batches", str(args.prefetch_batches)]
+ if task == "spatial_grounding":
+ cmd = [
+ PYTHON,
+ str(PROJECT_DIR / "eval/task/spatial_grounding/eval_refcoco_vllm.py"),
+ "--model_path", model,
+ "--processor_path", str(d["processor_path"] or model),
+ "--bench_dir", str(d["bench_dir"]),
+ "--datasets", str(d["datasets"]),
+ "--output_dir", str(out),
+ "--prompt_style", str(d["prompt_style"]),
+ "--coord_system", str(d["coord_system"]),
+ "--bbox_select", str(d["bbox_select"]),
+ "--enable_thinking", "true" if d["enable_thinking"] else "false",
+ "--min_tokens", str(d["min_tokens"]),
+ "--total_tokens", str(d["total_tokens"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--batch_size", str(d["batch_size"]),
+ "--tensor_parallel_size", str(args.tp_size),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_samples", str(d["max_samples"]),
+ "--chunk", str(len(gpu_groups)),
+ "--index", str(i),
+ "--seed", str(args.seed),
+ ]
+ if task in {"mmsi", "mindcube"}:
+ cmd += [
+ "--max_images", str(d["max_images"]),
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ ]
+ if task == "mindcube":
+ cmd += [
+ "--expected_samples",
+ str(d["expected_samples"]),
+ ]
+ if task in {"videomme", "videommev2"}:
+ cmd += [
+ "--video_base", d["video_base"],
+ "--video_dir", d["video_dir"],
+ "--preprocessed_video_dir", d["preprocessed_video_dir"],
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--prompt_mode", str(d.get("prompt_mode", "default")),
+ "--answer_filter", str(d.get("answer_filter", "")),
+ ]
+ cmd += ["--max_samples", str(d["max_samples"])]
+ if task == "videommmu":
+ cmd += [
+ "--preprocessed_video_dir", d["video_root"],
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--prompt_mode", str(d["prompt_mode"]),
+ "--image_max_pixels", str(d["image_max_pixels"]),
+ "--temperature", str(d["temperature"]),
+ "--top_p", str(d["top_p"]),
+ "--top_k", str(d["top_k"]),
+ "--presence_penalty", str(d["presence_penalty"]),
+ "--min_p", str(d["min_p"]),
+ ]
+ if d["enable_thinking"]:
+ cmd.append("--enable_thinking")
+ if task == "mmvu":
+ cmd += [
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--prompt_mode", str(d["prompt_mode"]),
+ "--temperature", str(d["temperature"]),
+ "--top_p", str(d["top_p"]),
+ "--top_k", str(d["top_k"]),
+ "--presence_penalty", str(d["presence_penalty"]),
+ "--min_p", str(d["min_p"]),
+ ]
+ if d["enable_thinking"]:
+ cmd.append("--enable_thinking")
+ if task == "mvbench":
+ cmd += [
+ "--preprocessed_video_dir", str(d["video_root"]),
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--prompt_mode", str(d["prompt_mode"]),
+ "--temperature", str(d["temperature"]),
+ "--top_p", str(d["top_p"]),
+ "--top_k", str(d["top_k"]),
+ "--presence_penalty", str(d["presence_penalty"]),
+ "--min_p", str(d["min_p"]),
+ ]
+ if d["enable_thinking"]:
+ cmd.append("--enable_thinking")
+ if task == "videoholmes":
+ cmd += [
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--prompt_mode", str(d["prompt_mode"]),
+ "--temperature", str(d["temperature"]),
+ "--top_p", str(d["top_p"]),
+ "--top_k", str(d["top_k"]),
+ "--presence_penalty", str(d["presence_penalty"]),
+ "--min_p", str(d["min_p"]),
+ ]
+ if d["enable_thinking"]:
+ cmd.append("--enable_thinking")
+ if task == "longvideobench":
+ cmd += [
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--prompt_mode", str(d["prompt_mode"]),
+ "--use_subtitles",
+ "true" if d["use_subtitles"] else "false",
+ "--temperature", str(d["temperature"]),
+ "--top_p", str(d["top_p"]),
+ "--top_k", str(d["top_k"]),
+ "--presence_penalty", str(d["presence_penalty"]),
+ "--min_p", str(d["min_p"]),
+ ]
+ if d["enable_thinking"]:
+ cmd.append("--enable_thinking")
+ if task == "lvbench":
+ cmd += [
+ "--preprocessed_video_dir", str(d["video_root"]),
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--prompt_mode", str(d["prompt_mode"]),
+ "--temperature", str(d["temperature"]),
+ "--top_p", str(d["top_p"]),
+ "--top_k", str(d["top_k"]),
+ "--presence_penalty", str(d["presence_penalty"]),
+ "--min_p", str(d["min_p"]),
+ ]
+ if d["enable_thinking"]:
+ cmd.append("--enable_thinking")
+ if task == "mlvu":
+ cmd += [
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--prompt_mode", str(d["prompt_mode"]),
+ "--temperature", str(d["temperature"]),
+ "--top_p", str(d["top_p"]),
+ "--top_k", str(d["top_k"]),
+ "--presence_penalty", str(d["presence_penalty"]),
+ "--min_p", str(d["min_p"]),
+ ]
+ if d["enable_thinking"]:
+ cmd.append("--enable_thinking")
+ if task == "vsi":
+ cmd += [
+ "--preprocessed_video_dir",
+ d["preprocessed_video_dir"],
+ "--max_model_len",
+ str(d["max_model_len"]),
+ "--max_new_tokens",
+ str(d["max_new_tokens"]),
+ "--gpu_memory_utilization",
+ str(d["gpu_memory_utilization"]),
+ "--video_min_pixels",
+ str(d["video_min_pixels"]),
+ "--video_max_pixels",
+ str(d["video_max_pixels"]),
+ "--video_total_pixels",
+ str(d["video_total_pixels"]),
+ "--max_frames",
+ str(d["max_frames"]),
+ "--fps",
+ str(d["fps"]),
+ ]
+ if task == "tracking":
+ cmd += [
+ "--processor_path", str(d["processor_path"]),
+ "--bench_dir", str(d["bench_dir"]),
+ "--base_prefix", str(d["base_prefix"]),
+ "--datasets", str(d["datasets"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--prompt_mode", str(d["prompt_mode"]),
+ "--chunked_reprompt", str(d["chunked_reprompt"]),
+ "--max_samples", str(d["max_samples"]),
+ ]
+ if d["enable_thinking"]:
+ cmd.append("--enable_thinking")
+ if d["reprompt_use_gt_first_box"]:
+ cmd.append("--reprompt_use_gt_first_box")
+ if task == "stvg":
+ cmd += [
+ "--processor_path", str(d["processor_path"]),
+ "--bench_dir", str(d["bench_dir"]),
+ "--base_prefix", str(d["base_prefix"]),
+ "--datasets", str(d["datasets"]),
+ "--video_min_pixels", str(d["video_min_pixels"]),
+ "--video_max_pixels", str(d["video_max_pixels"]),
+ "--video_total_pixels", str(d["video_total_pixels"]),
+ "--max_frames", str(d["max_frames"]),
+ "--fps", str(d["fps"]),
+ "--max_model_len", str(d["max_model_len"]),
+ "--max_new_tokens", str(d["max_new_tokens"]),
+ "--max_num_batched_tokens", str(d["max_num_batched_tokens"]),
+ "--gpu_memory_utilization", str(d["gpu_memory_utilization"]),
+ "--prompt_mode", str(d["prompt_mode"]),
+ "--max_samples", str(d["max_samples"]),
+ ]
+ if d["enable_thinking"]:
+ cmd.append("--enable_thinking")
+ if task in {
+ "mmvu",
+ "mvbench",
+ "videoholmes",
+ "longvideobench",
+ "mlvu",
+ }:
+ cmd += ["--max_samples", str(d["max_samples"])]
+ cmds.append(cmd)
+
+ run_config = {
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "task": task,
+ "model_path": model,
+ "model_family": model_tags(model)[0],
+ "checkpoint": model_tags(model)[1],
+ "output_dir": str(out),
+ "setting": d.get("setting"),
+ "data_file": d.get("data_file"),
+ "gpu_groups": gpu_groups,
+ "tp_size": args.tp_size,
+ "task_defaults": json_safe(d),
+ "commands": [" ".join(shlex.quote(str(x)) for x in cmd) for cmd in cmds],
+ }
+ write_json(out, "run_config.json", run_config)
+ if task in {"videomme", "videommev2"}:
+ try:
+ records = read_json_or_jsonl(str(d["data_file"]))
+ if records:
+ records.sort(key=lambda r: (str(r.get("videoID") or r.get("video_id") or ""), str(r.get("question_id") or r.get("id") or "")))
+ preview = videomme_prompt(records[0], str(d.get("prompt_mode", "default")))
+ (out / "prompt_preview.txt").write_text(preview, encoding="utf-8")
+ except Exception as exc:
+ (out / "prompt_preview.txt").write_text(f"[failed to render preview] {exc}\n", encoding="utf-8")
+
+ print("\n" + "=" * 60, flush=True)
+ print(f"Running {task}", flush=True)
+ print(f"Model: {model}", flush=True)
+ print(f"Output: {out}", flush=True)
+ print(f"Run config: {out / 'run_config.json'}", flush=True)
+ print("=" * 60, flush=True)
+ procs = []
+ for i, (cmd, gpus) in enumerate(zip(cmds, gpu_groups)):
+ env = os.environ.copy()
+ env["CUDA_VISIBLE_DEVICES"] = gpus
+ env["PYTHONUNBUFFERED"] = "1"
+ log = (out / f"worker_{i}.log").open("w", encoding="utf-8")
+ log.write("=" * 60 + "\n")
+ log.write("OraRL Eval Worker\n")
+ log.write(f"created_at: {run_config['created_at']}\n")
+ log.write(f"task: {task}\n")
+ log.write(f"model_path: {model}\n")
+ log.write(f"output_dir: {out}\n")
+ log.write(f"setting: {d.get('setting')}\n")
+ log.write(f"data_file: {d.get('data_file')}\n")
+ if d.get("preprocessed_video_dir"):
+ log.write(f"preprocessed_video_dir: {d.get('preprocessed_video_dir')}\n")
+ if d.get("video_root"):
+ log.write(f"video_root: {d.get('video_root')}\n")
+ log.write(f"gpu_group: {gpus}\n")
+ log.write(f"tp_size: {args.tp_size}\n")
+ log.write(f"command: {' '.join(shlex.quote(str(x)) for x in cmd)}\n")
+ log.write(f"task_defaults: {json.dumps(json_safe(d), ensure_ascii=False, sort_keys=True)}\n")
+ log.write("=" * 60 + "\n")
+ log.flush()
+ proc = subprocess.Popen(cmd, cwd=PROJECT_DIR, env=env, stdout=log, stderr=subprocess.STDOUT)
+ procs.append((i, proc, log))
+ print(f"Launched shard {i} on GPU {gpus} (PID {proc.pid})", flush=True)
+ if i < len(cmds) - 1:
+ time.sleep(float(os.environ.get("LAUNCH_DELAY", "3")))
+
+ failed = False
+ remaining = set(range(len(procs)))
+ while remaining:
+ for i, proc, log in procs:
+ if i not in remaining:
+ continue
+ rc = proc.poll()
+ if rc is None:
+ continue
+ remaining.remove(i)
+ log.close()
+ print(("[DONE]" if rc == 0 else "[FAIL]") + f" shard {i} exit={rc}", flush=True)
+ if rc != 0:
+ failed = True
+ for j, other, other_log in procs:
+ if j in remaining and other.poll() is None:
+ print(f"[KILL] shard {j} after shard {i} failed", flush=True)
+ other.terminate()
+ for j, other, other_log in procs:
+ if j in remaining:
+ try:
+ other.wait(timeout=30)
+ except subprocess.TimeoutExpired:
+ other.kill()
+ other.wait()
+ other_log.close()
+ remaining.clear()
+ break
+ if remaining:
+ time.sleep(2)
+
+ if failed:
+ raise RuntimeError(f"{task} failed; logs under {out}")
+ merge_task(task, out, len(gpu_groups))
+
+
+def parse_tasks(value: str) -> List[str]:
+ if value == "all":
+ return list(TASKS)
+ tasks = [x.strip() for x in value.split(",") if x.strip()]
+ bad = [x for x in tasks if x not in TASKS]
+ if bad:
+ raise ValueError(f"Unknown task(s): {bad}; available={TASKS}")
+ return tasks
+
+
+def worker_main(args) -> None:
+ print(
+ f"[worker] task={args.task} shard={args.index}/{args.chunk} "
+ f"CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', '')}",
+ flush=True,
+ )
+ if args.task in {"videomme", "videommev2"}:
+ worker_videomme(args)
+ elif args.task == "videommmu":
+ worker_videommmu(args)
+ elif args.task == "mmvu":
+ worker_mmvu(args)
+ elif args.task == "mvbench":
+ worker_mvbench(args)
+ elif args.task == "videoholmes":
+ worker_videoholmes(args)
+ elif args.task == "longvideobench":
+ worker_longvideobench(args)
+ elif args.task == "lvbench":
+ worker_lvbench(args)
+ elif args.task == "mlvu":
+ worker_mlvu(args)
+ elif args.task == "vsi":
+ worker_vsi(args)
+ elif args.task in {"mmsi", "mindcube"}:
+ worker_image_mc(args)
+ elif args.task == "tracking":
+ worker_tracking(args)
+ elif args.task == "stvg":
+ worker_stvg(args)
+ else:
+ raise ValueError(args.task)
+
+
+def main() -> None:
+ p = argparse.ArgumentParser()
+ p.add_argument("--model_path", default=os.environ.get("MODEL_PATH", ""))
+ p.add_argument("--tasks", default=os.environ.get("TASKS", "all"))
+ p.add_argument("--gpus", default=os.environ.get("GPUS", "0,1,2,3,4,5,6,7"))
+ p.add_argument("--tp_size", type=int, default=int(os.environ.get("TP_SIZE", "1")))
+ p.add_argument("--worker", action="store_true")
+ p.add_argument("--task", choices=TASKS)
+ p.add_argument("--output_dir", default="")
+ p.add_argument("--chunk", type=int, default=1)
+ p.add_argument("--index", type=int, default=0)
+ p.add_argument("--data_file", default="")
+ p.add_argument("--video_base", default="")
+ p.add_argument("--video_dir", default="")
+ p.add_argument("--preprocessed_video_dir", default="")
+ p.add_argument("--batch_size", type=int, default=16)
+ p.add_argument("--prefetch_batches", type=int, default=int(os.environ.get("PREFETCH_BATCHES", "1")))
+ p.add_argument("--max_images", type=int, default=8)
+ p.add_argument("--expected_samples", type=int, default=0)
+ p.add_argument("--max_model_len", type=int, default=32768)
+ p.add_argument("--max_new_tokens", type=int, default=16)
+ p.add_argument("--max_num_batched_tokens", type=int, default=32768)
+ p.add_argument("--gpu_memory_utilization", type=float, default=0.95)
+ p.add_argument("--processor_path", default="")
+ p.add_argument("--bench_dir", default="")
+ p.add_argument("--base_prefix", default="")
+ p.add_argument("--datasets", default="eval_got10k")
+ p.add_argument("--max_samples", type=int, default=0)
+ p.add_argument("--image_max_pixels", type=int, default=0)
+ p.add_argument("--video_min_pixels", type=int, default=65536)
+ p.add_argument("--video_max_pixels", type=int, default=8388608)
+ p.add_argument("--video_total_pixels", type=int, default=8388608)
+ p.add_argument("--max_frames", type=int, default=32)
+ p.add_argument("--fps", type=int, default=1)
+ p.add_argument("--enable_thinking", action="store_true")
+ p.add_argument("--prompt_mode", choices=["default", "bare", "onethink", "onethink_cot", "qwen", "holmes", "train_stvg", "explicit_ah", "careful_ah"], default="default")
+ p.add_argument("--answer_filter", default="")
+ p.add_argument("--use_subtitles", choices=["true", "false"], default="true")
+ p.add_argument("--chunked_reprompt", type=int, default=0)
+ p.add_argument("--reprompt_use_gt_first_box", action="store_true")
+ p.add_argument("--enforce_eager", action="store_true")
+ p.add_argument("--temperature", type=float, default=0.0)
+ p.add_argument("--top_p", type=float, default=1.0)
+ p.add_argument("--top_k", type=int, default=-1)
+ p.add_argument("--presence_penalty", type=float, default=0.0)
+ p.add_argument("--min_p", type=float, default=0.0)
+ p.add_argument("--seed", type=int, default=42)
+ args = p.parse_args()
+ args.use_subtitles = args.use_subtitles == "true"
+ if args.max_samples < 0:
+ p.error("--max_samples must be non-negative")
+
+ if not args.model_path:
+ raise ValueError("Missing model path; set MODEL_PATH in eval/task/eval.sh or pass --model_path.")
+
+ if args.worker:
+ worker_main(args)
+ return
+
+ gpu_groups = split_gpus(args.gpus, args.tp_size)
+ for task in parse_tasks(args.tasks):
+ launch_task(task, args.model_path, gpu_groups, args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/mindcube/data_utils.py b/code/eval/task/mindcube/data_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..8dd33b62c44dabf1bb633466ea832795a4250bc4
--- /dev/null
+++ b/code/eval/task/mindcube/data_utils.py
@@ -0,0 +1,254 @@
+"""Official MindCube-Tiny data loading and record normalization."""
+
+from __future__ import annotations
+
+import io
+import re
+import sys
+from collections import Counter
+from pathlib import Path
+from typing import Any, Dict, List
+
+from PIL import Image
+
+_TASK_DIR = Path(__file__).resolve().parents[1]
+if str(_TASK_DIR) not in sys.path:
+ sys.path.insert(0, str(_TASK_DIR))
+
+from canonical_data import load_json_records # noqa: E402
+
+OFFICIAL_DATA_SOURCE = (
+ "hf://datasets/oscarqjh/MindCube_lmmseval"
+ "@7dd2725d9bd4149f2aad00a9843f72a3824da003/tiny/combined"
+)
+OFFICIAL_EXPECTED_SAMPLES = 1050
+OFFICIAL_GROUP_COUNTS = {
+ "rotation": 200,
+ "among": 600,
+ "around": 250,
+}
+HF_DATASET_PREFIX = "hf://datasets/"
+CHOICES = list("ABCDEFGH")
+
+
+def parse_hf_dataset_source(
+ source: str,
+) -> tuple[str, str | None, str, str]:
+ """Parse hf://datasets//[@revision]//."""
+ if not source.startswith(HF_DATASET_PREFIX):
+ raise ValueError(f"Not a Hugging Face dataset source: {source}")
+ payload = source[len(HF_DATASET_PREFIX) :]
+ try:
+ dataset_spec, config, split = payload.rsplit("/", 2)
+ except ValueError as error:
+ raise ValueError(
+ "MindCube Hugging Face source must use "
+ "hf://datasets////"
+ ) from error
+ dataset_name, separator, revision = dataset_spec.rpartition("@")
+ if not separator:
+ dataset_name = dataset_spec
+ revision = None
+ if not dataset_name or "/" not in dataset_name or not config or not split:
+ raise ValueError(f"Invalid MindCube Hugging Face source: {source}")
+ return dataset_name, revision, config, split
+
+
+def mindcube_group(record: Dict[str, Any]) -> str:
+ """Return one of the official Rotation/Among/Around settings."""
+ for key in ("task", "setting"):
+ value = str(record.get(key) or "").strip().lower()
+ if value in OFFICIAL_GROUP_COUNTS:
+ return value
+
+ sample_id = str(
+ record.get("id")
+ or record.get("index")
+ or record.get("sample_id")
+ or ""
+ ).strip().lower()
+ match = re.match(r"^(rotation|among|around)(?:_|$)", sample_id)
+ return match.group(1) if match else "unknown"
+
+
+def _validate_records(
+ records: Any,
+ expected_samples: int,
+) -> None:
+ if expected_samples <= 0:
+ return
+ actual = len(records)
+ if actual != expected_samples:
+ raise RuntimeError(
+ f"Expected {expected_samples} official MindCube-Tiny samples, "
+ f"got {actual}. Refusing to evaluate a reduced or different split."
+ )
+ if expected_samples != OFFICIAL_EXPECTED_SAMPLES:
+ return
+
+ if hasattr(records, "column_names") and "id" in records.column_names:
+ groups = Counter(
+ mindcube_group({"id": sample_id}) for sample_id in records["id"]
+ )
+ else:
+ groups = Counter(mindcube_group(record) for record in records)
+ actual_groups = {
+ group: groups.get(group, 0) for group in OFFICIAL_GROUP_COUNTS
+ }
+ if actual_groups != OFFICIAL_GROUP_COUNTS or groups.get("unknown", 0):
+ raise RuntimeError(
+ "Official MindCube-Tiny must contain "
+ f"{OFFICIAL_GROUP_COUNTS}, got {dict(groups)}."
+ )
+
+
+def load_mindcube_records(
+ source: str,
+ *,
+ chunk: int = 1,
+ index: int = 0,
+ expected_samples: int = OFFICIAL_EXPECTED_SAMPLES,
+ cache_dir: str | None = None,
+) -> List[Dict[str, Any]]:
+ """Load and shard the official split before decoding its images."""
+ if chunk <= 0 or index < 0 or index >= chunk:
+ raise ValueError(f"Invalid shard index {index}/{chunk}")
+
+ if source.startswith(HF_DATASET_PREFIX):
+ from datasets import load_dataset
+
+ dataset_name, revision, config, split = parse_hf_dataset_source(
+ source
+ )
+ records = load_dataset(
+ dataset_name,
+ config,
+ split=split,
+ revision=revision,
+ cache_dir=cache_dir or None,
+ )
+ _validate_records(records, expected_samples)
+ if chunk > 1:
+ records = records.shard(
+ num_shards=chunk,
+ index=index,
+ contiguous=False,
+ )
+ return [records[row_index] for row_index in range(len(records))]
+
+ if source.endswith((".jsonl", ".json")):
+ records = load_json_records(source)
+ _validate_records(records, expected_samples)
+ return records[index::chunk]
+
+ import pandas as pd
+
+ if not source.endswith(".parquet"):
+ raise ValueError(
+ "MindCube data must be the official Hugging Face source or a "
+ f"local official parquet, got: {source}"
+ )
+ records = pd.read_parquet(source).to_dict("records")
+ _validate_records(records, expected_samples)
+ return records[index::chunk]
+
+
+def decode_hf_image(
+ value: Any,
+ min_image_bytes: int,
+) -> Image.Image | None:
+ if value is None:
+ return None
+ if isinstance(value, Image.Image):
+ return value.convert("RGB")
+
+ raw = None
+ path = None
+ if isinstance(value, dict):
+ raw = value.get("bytes")
+ path = value.get("path")
+ elif isinstance(value, (bytes, bytearray, memoryview)):
+ raw = value
+ elif isinstance(value, str):
+ path = value
+
+ if raw is None and path:
+ return Image.open(path).convert("RGB")
+ if raw is None:
+ return None
+ raw = bytes(raw)
+ if min_image_bytes > 0 and len(raw) < min_image_bytes:
+ return None
+ return Image.open(io.BytesIO(raw)).convert("RGB")
+
+
+def decode_mindcube_images(
+ record: Dict[str, Any],
+ max_images: int,
+ min_image_bytes: int,
+) -> List[Image.Image]:
+ values = record.get("images")
+ if values is not None:
+ if hasattr(values, "tolist"):
+ values = values.tolist()
+ if not isinstance(values, (list, tuple)):
+ values = [values]
+ else:
+ values = [
+ record.get(f"image_{idx}") for idx in range(max_images)
+ ]
+
+ images = []
+ for value in values[:max_images]:
+ image = decode_hf_image(value, min_image_bytes)
+ if image is not None:
+ images.append(image)
+ return images
+
+
+def normalize_choices(value: Any) -> List[str]:
+ if value is None:
+ return []
+ if hasattr(value, "tolist"):
+ value = value.tolist()
+ if isinstance(value, (list, tuple)):
+ return [str(item) for item in value]
+ return [str(value)]
+
+
+def labels_from_question(question: str) -> List[str]:
+ labels = []
+ for label in re.findall(
+ r"(?:^|[\s,])([A-H])\s*[:\).]",
+ question or "",
+ ):
+ if label not in labels:
+ labels.append(label)
+ return labels or list("ABCD")
+
+
+def mindcube_prompt(
+ record: Dict[str, Any],
+ prompt_tail: str,
+) -> tuple[str, List[str]]:
+ question = str(record.get("question") or "").strip()
+ choices = normalize_choices(record.get("choices"))
+ labels = CHOICES[: len(choices)] if choices else labels_from_question(
+ question
+ )
+
+ official_prompt = str(record.get("input_prompt") or "").strip()
+ if official_prompt:
+ return official_prompt, labels
+ if choices:
+ options = "\n".join(
+ f"{label}. {text}"
+ for label, text in zip(labels, choices)
+ )
+ return f"{question}\nOptions:\n{options}\n{prompt_tail}", labels
+ return f"{question}\n{prompt_tail}", labels
+
+
+def mindcube_answer_value(record: Dict[str, Any]) -> Any:
+ answer = record.get("gt_answer")
+ return record.get("answer") if answer is None else answer
diff --git a/code/eval/task/mmsi/eval_mmsi_transformers.py b/code/eval/task/mmsi/eval_mmsi_transformers.py
new file mode 100644
index 0000000000000000000000000000000000000000..3f0a114e61da1e400e0621cc709540e21d40d1b0
--- /dev/null
+++ b/code/eval/task/mmsi/eval_mmsi_transformers.py
@@ -0,0 +1,323 @@
+#!/usr/bin/env python3
+"""MMSI-Bench evaluation with Hugging Face Transformers.
+
+Unlike the vLLM evaluator, this backend can pass every image in a sample
+without configuring a fixed multimodal-item limit.
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import base64
+import io
+import json
+import logging
+import os
+import random
+import sys
+import time
+import warnings
+from pathlib import Path
+from typing import Any, Dict, List
+
+import numpy as np
+import torch
+from PIL import Image
+from qwen_vl_utils import process_vision_info
+from transformers import AutoModelForImageTextToText, AutoProcessor
+
+
+TASK_DIR = Path(__file__).resolve().parents[1]
+if str(TASK_DIR) not in sys.path:
+ sys.path.insert(0, str(TASK_DIR))
+
+from eval_image_mc_vllm import ( # noqa: E402
+ aggregate,
+ build_prompt,
+ extract_mcq_answer,
+ get_answer,
+ get_group_key,
+ load_records,
+ shard,
+)
+
+
+warnings.filterwarnings("ignore", message=".*pad_token_id.*")
+logging.getLogger("transformers").setLevel(logging.ERROR)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="MMSI-Bench evaluation via Hugging Face Transformers."
+ )
+ parser.add_argument("--model_path", required=True)
+ parser.add_argument("--processor_path", default="")
+ parser.add_argument("--data_file", required=True)
+ parser.add_argument("--output_dir", required=True)
+ parser.add_argument("--image_min_pixels", type=int, default=4096)
+ parser.add_argument("--image_max_pixels", type=int, default=262144)
+ parser.add_argument(
+ "--max_images",
+ type=int,
+ default=0,
+ help="Maximum images per sample; <=0 means use every image.",
+ )
+ parser.add_argument("--patch_size", type=int, default=16)
+ parser.add_argument("--max_new_tokens", type=int, default=1024)
+ parser.add_argument("--max_samples", type=int, default=0)
+ parser.add_argument("--category", default="")
+ parser.add_argument("--chunk", type=int, default=1)
+ parser.add_argument("--index", type=int, default=0)
+ parser.add_argument("--enable_thinking", action="store_true")
+ parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument("--save_interval", type=int, default=20)
+ parser.add_argument(
+ "--attn_implementation",
+ default="flash_attention_2",
+ choices=["flash_attention_2", "sdpa", "eager"],
+ )
+ args = parser.parse_args()
+ args.bench = "mmsi"
+ args.processor_path = args.processor_path or args.model_path
+ return args
+
+
+def set_seed(seed: int) -> None:
+ random.seed(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ if torch.cuda.is_available():
+ torch.cuda.manual_seed_all(seed)
+
+
+def decode_all_images(value: Any, max_images: int) -> List[Image.Image]:
+ values = ast.literal_eval(value) if isinstance(value, str) else value
+ if not isinstance(values, (list, tuple)):
+ values = [values]
+ if max_images > 0:
+ values = values[:max_images]
+
+ images: List[Image.Image] = []
+ for item in values:
+ if not item:
+ continue
+ path = ""
+ if isinstance(item, dict):
+ path = str(
+ item.get("path")
+ or item.get("image")
+ or item.get("image_path")
+ or ""
+ )
+ elif isinstance(item, str) and os.path.isfile(item):
+ path = item
+ if path:
+ with Image.open(path) as image:
+ images.append(image.convert("RGB"))
+ continue
+ raw = item if isinstance(item, bytes) else base64.b64decode(str(item))
+ with Image.open(io.BytesIO(raw)) as image:
+ images.append(image.convert("RGB"))
+ return images
+
+
+def prepare_inputs(
+ images: List[Image.Image],
+ prompt: str,
+ processor,
+ args: argparse.Namespace,
+):
+ content: List[Dict[str, Any]] = []
+ for image in images:
+ content.append(
+ {
+ "type": "image",
+ "image": image,
+ "min_pixels": args.image_min_pixels,
+ "max_pixels": args.image_max_pixels,
+ }
+ )
+ content.append({"type": "text", "text": prompt})
+ messages = [{"role": "user", "content": content}]
+ text = processor.apply_chat_template(
+ messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=args.enable_thinking,
+ )
+ image_inputs, _ = process_vision_info(
+ messages,
+ image_patch_size=args.patch_size,
+ )
+ return processor(
+ text=[text],
+ images=image_inputs,
+ padding=True,
+ return_tensors="pt",
+ )
+
+
+def sample_id(record: Dict[str, Any], fallback: int) -> str:
+ for key in ("index", "id", "sample_id"):
+ value = record.get(key)
+ if value is not None and str(value) != "":
+ return str(value)
+ return str(fallback)
+
+
+def write_progress(
+ output_path: Path,
+ summary_path: Path,
+ args: argparse.Namespace,
+ results: List[Dict[str, Any]],
+) -> None:
+ output_path.write_text(
+ json.dumps(results, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ summary_path.write_text(
+ json.dumps(aggregate(args, results), ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+
+def main() -> None:
+ args = parse_args()
+ if not torch.cuda.is_available():
+ raise RuntimeError("CUDA is required for MMSI Transformers evaluation.")
+ if args.chunk <= 0 or not 0 <= args.index < args.chunk:
+ raise ValueError(
+ f"Invalid shard index: index={args.index}, chunk={args.chunk}"
+ )
+
+ set_seed(args.seed)
+ output_dir = Path(args.output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ result_path = output_dir / f"results_mmsi_shard{args.index}.json"
+ summary_path = output_dir / f"summary_shard{args.index}.json"
+
+ print(f"Model: {args.model_path}", flush=True)
+ print(f"Processor: {args.processor_path}", flush=True)
+ print(f"Data: {args.data_file}", flush=True)
+ print(f"Shard: {args.index}/{args.chunk}", flush=True)
+ print(
+ "Images: "
+ f"{'all' if args.max_images <= 0 else args.max_images} "
+ f"(min_px={args.image_min_pixels}, max_px={args.image_max_pixels})",
+ flush=True,
+ )
+
+ print("Loading Transformers model ...", flush=True)
+ model = AutoModelForImageTextToText.from_pretrained(
+ args.model_path,
+ dtype=torch.bfloat16,
+ attn_implementation=args.attn_implementation,
+ device_map={"": "cuda:0"},
+ trust_remote_code=True,
+ ).eval()
+ processor = AutoProcessor.from_pretrained(
+ args.processor_path,
+ padding_side="left",
+ do_resize=False,
+ trust_remote_code=True,
+ )
+
+ records = load_records(args)
+ if args.category:
+ records = [
+ record
+ for record in records
+ if str(record.get("category") or "") == args.category
+ ]
+ if args.max_samples > 0:
+ records = records[: args.max_samples]
+ indexed_records = list(enumerate(records))
+ indexed_records = shard(indexed_records, args.chunk, args.index)
+ print(f"Loaded {len(indexed_records)} samples for this shard.", flush=True)
+
+ results: List[Dict[str, Any]] = []
+ started = time.time()
+ for local_index, (source_index, record) in enumerate(indexed_records, 1):
+ images: List[Image.Image] = []
+ raw_prediction = ""
+ error = ""
+ prompt, labels = build_prompt(args, record)
+ ground_truth = get_answer(args, record)
+ try:
+ images = decode_all_images(
+ record.get("images") or record.get("image"),
+ args.max_images,
+ )
+ if not images:
+ raise ValueError("sample contains no decodable images")
+ inputs = prepare_inputs(images, prompt, processor, args).to(
+ "cuda", non_blocking=True
+ )
+ input_length = inputs.input_ids.shape[-1]
+ with torch.inference_mode():
+ generated = model.generate(
+ **inputs,
+ do_sample=False,
+ max_new_tokens=args.max_new_tokens,
+ use_cache=True,
+ )
+ generated = generated[:, input_length:]
+ raw_prediction = processor.batch_decode(
+ generated,
+ skip_special_tokens=True,
+ clean_up_tokenization_spaces=False,
+ )[0]
+ del inputs, generated
+ except Exception as exc:
+ error = f"{type(exc).__name__}: {exc}"
+ torch.cuda.empty_cache()
+ print(
+ f"[warn] sample={source_index} images={len(images)}: {error}",
+ flush=True,
+ )
+ finally:
+ for image in images:
+ image.close()
+
+ prediction = extract_mcq_answer(raw_prediction, labels)
+ score = float(
+ bool(prediction)
+ and bool(ground_truth)
+ and prediction.upper() == ground_truth.upper()
+ )
+ results.append(
+ {
+ "id": sample_id(record, source_index),
+ "bench": "mmsi",
+ "group": get_group_key(args, record),
+ "question": record.get("question"),
+ "prompt": prompt,
+ "answer": ground_truth,
+ "pred_answer": prediction,
+ "raw_prediction": raw_prediction,
+ "num_images": len(images),
+ "score": score,
+ "error": error,
+ }
+ )
+
+ if (
+ local_index % args.save_interval == 0
+ or local_index == len(indexed_records)
+ ):
+ write_progress(result_path, summary_path, args, results)
+ summary = aggregate(args, results)
+ print(
+ f"[{local_index}/{len(indexed_records)}] "
+ f"{time.time() - started:.1f}s "
+ f"acc={summary['accuracy']:.2f}% "
+ f"parse={summary['parse_rate']:.2f}%",
+ flush=True,
+ )
+
+ print(json.dumps(aggregate(args, results), ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/mmsi/run_eval_transformers.sh b/code/eval/task/mmsi/run_eval_transformers.sh
new file mode 100644
index 0000000000000000000000000000000000000000..adda6167d54f795bac60ac3a3ce1463fb5d68f4f
--- /dev/null
+++ b/code/eval/task/mmsi/run_eval_transformers.sh
@@ -0,0 +1,214 @@
+#!/usr/bin/env bash
+# MMSI-Bench evaluation with one Transformers model replica per GPU.
+set -euo pipefail
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+PROJECT_DIR=$(cd -- "${SCRIPT_DIR}/../../.." && pwd)
+EVALUATOR="${SCRIPT_DIR}/eval_mmsi_transformers.py"
+
+MODEL_PATH=${1:-${MODEL_PATH:-}}
+PROCESSOR_PATH=${PROCESSOR_PATH:-${MODEL_PATH}}
+DATA_FILE=${DATA_FILE:-"${PROJECT_DIR}/data/eval/mmsi/MMSI_bench.tsv"}
+IMAGE_MIN_PIXELS=${IMAGE_MIN_PIXELS:-4096}
+IMAGE_MAX_PIXELS=${IMAGE_MAX_PIXELS:-262144}
+MAX_IMAGES=${MAX_IMAGES:-0}
+PATCH_SIZE=${PATCH_SIZE:-16}
+MAX_NEW_TOKENS=${MAX_NEW_TOKENS:-1024}
+MAX_SAMPLES=${MAX_SAMPLES:-0}
+CATEGORY=${CATEGORY:-}
+ENABLE_THINKING=${ENABLE_THINKING:-false}
+ATTN_IMPLEMENTATION=${ATTN_IMPLEMENTATION:-flash_attention_2}
+SAVE_INTERVAL=${SAVE_INTERVAL:-20}
+if [[ -n "${CATEGORY}" || "${MAX_SAMPLES}" != "0" ]]; then
+ EXPECTED_SAMPLES=${EXPECTED_SAMPLES:-0}
+else
+ EXPECTED_SAMPLES=${EXPECTED_SAMPLES:-1000}
+fi
+LAUNCH_DELAY=${LAUNCH_DELAY:-2}
+OUTPUT_ROOT=${OUTPUT_ROOT:-"${PROJECT_DIR}/outputs/mmsi"}
+
+[[ -n "${MODEL_PATH}" ]] || {
+ echo "[FATAL] MODEL_PATH is required." >&2
+ exit 1
+}
+for path in "${EVALUATOR}" "${MODEL_PATH}/config.json" "${DATA_FILE}"; do
+ [[ -f "${path}" ]] || {
+ echo "[FATAL] Required input is unavailable: ${path}" >&2
+ exit 1
+ }
+done
+compgen -G "${MODEL_PATH}/*.safetensors" >/dev/null || {
+ echo "[FATAL] No safetensors weights found under ${MODEL_PATH}." >&2
+ exit 1
+}
+
+IFS=',' read -ra GPULIST <<<"${CUDA_VISIBLE_DEVICES:-$(seq -s, 0 $(($(nvidia-smi -L | wc -l) - 1)))}"
+NUM_GPUS=${#GPULIST[@]}
+((NUM_GPUS > 0)) || {
+ echo "[FATAL] No visible GPUs." >&2
+ exit 1
+}
+unset CUDA_VISIBLE_DEVICES
+
+model_name=$(basename "${MODEL_PATH%/}")
+model_parent=$(basename "$(dirname "${MODEL_PATH%/}")")
+model_grandparent=$(basename "$(dirname "$(dirname "${MODEL_PATH%/}")")")
+if [[ "${model_name}" == "huggingface" && "${model_parent}" == "actor" && "${model_grandparent}" == global_step_* ]]; then
+ MODEL_FAMILY=$(basename "$(dirname "$(dirname "$(dirname "${MODEL_PATH%/}")")")")
+ CHECKPOINT_TAG=${model_grandparent}
+elif [[ "${model_name}" == checkpoint-* ]]; then
+ MODEL_FAMILY=$(basename "$(dirname "$(dirname "${MODEL_PATH%/}")")")
+ CHECKPOINT_TAG=${model_name}
+else
+ MODEL_FAMILY=${model_name}
+ CHECKPOINT_TAG=base
+fi
+
+DATA_TAG=$(basename "${DATA_FILE}")
+DATA_TAG=${DATA_TAG%.*}
+IMAGE_COUNT_TAG=$([[ "${MAX_IMAGES}" -le 0 ]] && echo all || echo "${MAX_IMAGES}")
+SETTING_TAG="transformers-img-min${IMAGE_MIN_PIXELS}-max${IMAGE_MAX_PIXELS}-n${IMAGE_COUNT_TAG}-all-${DATA_TAG}"
+OUTPUT_DIR=${OUTPUT_DIR:-"${OUTPUT_ROOT}/${MODEL_FAMILY}/${CHECKPOINT_TAG}/${SETTING_TAG}/$(date +%Y%m%d_%H%M%S)"}
+mkdir -p "${OUTPUT_DIR}"
+
+echo "============================================================"
+echo "MMSI-Bench Evaluation (Transformers, data-parallel)"
+echo "============================================================"
+echo "Model: ${MODEL_PATH}"
+echo "Processor: ${PROCESSOR_PATH}"
+echo "Data: ${DATA_FILE}"
+echo "GPUs: ${GPULIST[*]} (${NUM_GPUS} replicas)"
+echo "Images: $([[ "${MAX_IMAGES}" -le 0 ]] && echo all || echo "${MAX_IMAGES}")"
+echo "Pixels: min=${IMAGE_MIN_PIXELS} max=${IMAGE_MAX_PIXELS}"
+echo "Max new: ${MAX_NEW_TOKENS}"
+echo "Thinking: ${ENABLE_THINKING}"
+echo "Output: ${OUTPUT_DIR}"
+echo "============================================================"
+
+pids=()
+cleanup() {
+ local status=$?
+ trap - EXIT INT TERM
+ for pid in "${pids[@]:-}"; do
+ [[ -n "${pid}" ]] && kill "${pid}" >/dev/null 2>&1 || true
+ done
+ wait >/dev/null 2>&1 || true
+ exit "${status}"
+}
+trap cleanup EXIT INT TERM
+
+for index in "${!GPULIST[@]}"; do
+ gpu=${GPULIST[index]}
+ args=(
+ --model_path "${MODEL_PATH}"
+ --processor_path "${PROCESSOR_PATH}"
+ --data_file "${DATA_FILE}"
+ --output_dir "${OUTPUT_DIR}"
+ --image_min_pixels "${IMAGE_MIN_PIXELS}"
+ --image_max_pixels "${IMAGE_MAX_PIXELS}"
+ --max_images "${MAX_IMAGES}"
+ --patch_size "${PATCH_SIZE}"
+ --max_new_tokens "${MAX_NEW_TOKENS}"
+ --max_samples "${MAX_SAMPLES}"
+ --category "${CATEGORY}"
+ --chunk "${NUM_GPUS}"
+ --index "${index}"
+ --attn_implementation "${ATTN_IMPLEMENTATION}"
+ --save_interval "${SAVE_INTERVAL}"
+ )
+ if [[ "${ENABLE_THINKING,,}" == "true" || "${ENABLE_THINKING}" == "1" ]]; then
+ args+=(--enable_thinking)
+ fi
+ CUDA_VISIBLE_DEVICES="${gpu}" PYTHONUNBUFFERED=1 \
+ python "${EVALUATOR}" "${args[@]}" \
+ >"${OUTPUT_DIR}/worker_${index}.log" 2>&1 &
+ pids+=("$!")
+ echo "Launched shard ${index}/${NUM_GPUS} on GPU ${gpu} (PID ${pids[-1]})"
+ if ((index + 1 < NUM_GPUS)) && ((LAUNCH_DELAY > 0)); then
+ sleep "${LAUNCH_DELAY}"
+ fi
+done
+
+failed=0
+for index in "${!pids[@]}"; do
+ if ! wait "${pids[index]}"; then
+ echo "[FAIL] shard ${index}; inspect ${OUTPUT_DIR}/worker_${index}.log" >&2
+ failed=1
+ else
+ echo "[DONE] shard ${index}"
+ fi
+done
+pids=()
+((failed == 0)) || exit 1
+
+python - "${OUTPUT_DIR}" "${NUM_GPUS}" "${EXPECTED_SAMPLES}" "${MAX_IMAGES}" <<'PY'
+import json
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+output_dir = Path(sys.argv[1])
+num_shards = int(sys.argv[2])
+expected = int(sys.argv[3])
+max_images = int(sys.argv[4])
+records_by_id = {}
+for shard in range(num_shards):
+ path = output_dir / f"results_mmsi_shard{shard}.json"
+ if not path.is_file():
+ raise RuntimeError(f"Missing MMSI result shard: {path}")
+ for record in json.loads(path.read_text(encoding="utf-8")):
+ records_by_id[str(record["id"])] = record
+
+records = list(records_by_id.values())
+if expected > 0 and len(records) != expected:
+ raise RuntimeError(
+ f"Expected {expected} unique MMSI samples, got {len(records)}"
+ )
+
+correct = sum(int(record.get("score", 0)) for record in records)
+parsed = sum(bool(record.get("pred_answer")) for record in records)
+errors = sum(bool(record.get("error")) for record in records)
+groups = defaultdict(lambda: {"correct": 0, "total": 0})
+for record in records:
+ group = str(record.get("group") or "unknown")
+ groups[group]["total"] += 1
+ groups[group]["correct"] += int(record.get("score", 0))
+
+def pct(value, total):
+ return round(100.0 * value / total, 2) if total else 0.0
+
+summary = {
+ "backend": "transformers",
+ "image_policy": "all" if max_images <= 0 else f"first_{max_images}",
+ "num_samples": len(records),
+ "correct": correct,
+ "accuracy": pct(correct, len(records)),
+ "parse_rate": pct(parsed, len(records)),
+ "errors": errors,
+ "by_category": {
+ key: {
+ "accuracy": pct(value["correct"], value["total"]),
+ "correct": value["correct"],
+ "total": value["total"],
+ }
+ for key, value in sorted(groups.items())
+ },
+}
+(output_dir / "results_mmsi.json").write_text(
+ json.dumps(records, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+)
+(output_dir / "summary.json").write_text(
+ json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+)
+print(
+ f"MMSI Transformers: n={len(records)} "
+ f"acc={summary['accuracy']:.2f}% "
+ f"parse={summary['parse_rate']:.2f}% errors={errors}"
+)
+print(f"Summary: {output_dir / 'summary.json'}")
+PY
+
+chmod -R a+rX "${OUTPUT_DIR}" 2>/dev/null || true
+echo "MMSI Transformers evaluation complete: ${OUTPUT_DIR}"
diff --git a/code/eval/task/qwenvl_decord_patch.py b/code/eval/task/qwenvl_decord_patch.py
new file mode 100644
index 0000000000000000000000000000000000000000..37582cda533c50222a6daeee8869314706ad3e9a
--- /dev/null
+++ b/code/eval/task/qwenvl_decord_patch.py
@@ -0,0 +1,271 @@
+"""Monkey-patch qwen_vl_utils to force the decord video backend.
+
+Why this is needed
+==================
+
+* Evaluators call ``process_vision_info(..., return_video_metadata=True)``,
+ which is the **new-API** form of ``qwen_vl_utils``. New-API
+ ``fetch_video`` expects the underlying backend reader to return a
+ 3-tuple ``(video_tensor, video_metadata, sample_fps)``.
+
+* Newer ``qwen_vl_utils`` (>= 0.0.10) hard-codes
+ ``VIDEO_READER_BACKENDS["torchvision"](ele)`` inside ``fetch_video``, so
+ ``FORCE_QWENVL_VIDEO_READER=decord`` no longer takes effect. The
+ torchvision reader on some machines fails with
+ ``KeyError: 'video_fps'`` because the mp4 metadata is incomplete.
+
+* Older ``_read_video_decord`` (<= 0.0.8) returns a single tensor; if we
+ just alias ``"torchvision" -> _read_video_decord``, the 3-value unpack
+ in ``fetch_video`` blows up with ``RuntimeError: Error reading ``.
+
+Strategy
+========
+
+Replace ``vp.fetch_video`` with our own version that uses ``decord``
+directly and returns the **new-API 3-tuple** the rest of qwen_vl_utils
+expects. This way both old- and new-API call sites work, and we never
+have to ship a patched copy of qwen_vl_utils itself.
+
+Disable the patch with ``DISABLE_QWENVL_DECORD_PATCH=1`` (e.g. when
+debugging on a machine where torchvision works fine).
+"""
+from __future__ import annotations
+
+import math
+import os
+import sys
+from typing import Any
+
+
+def _smart_nframes(ele: dict, *, total_frames: int, video_fps: float) -> int:
+ """Mirror qwen_vl_utils.vision_process.smart_nframes: pick the number
+ of frames to sample given fps / max_frames hints in the message dict.
+
+ We re-implement instead of importing because qwen_vl_utils versions
+ differ on the public name (``smart_nframes`` vs internal helper).
+ """
+ FPS = float(ele.get("fps", 2.0))
+ MIN_FRAMES = int(ele.get("min_frames", 4))
+ MAX_FRAMES = int(ele.get("max_frames", 768))
+ nframes = ele.get("nframes")
+ if nframes is not None:
+ return max(1, min(int(nframes), total_frames))
+ duration = total_frames / max(1e-6, video_fps)
+ if FPS <= 0:
+ FPS = 2.0
+ n = int(round(duration * FPS))
+ n = max(MIN_FRAMES, min(MAX_FRAMES, n))
+ n = max(1, min(n, total_frames))
+ return n
+
+
+def _resolve_video_path(video_path: str) -> str:
+ """If the input path doesn't exist, try a few fallbacks before giving up.
+
+ Benchmark annotations join a base prefix with a relative media path, so a
+ layout that nests media one level differently than the annotation expects
+ (for example ``./Evaluation/got10k/.mp4`` against an on-disk
+ `` /got10k/.mp4``) would otherwise fail to decode.
+
+ Resolution order:
+ 0. exact path (default)
+ 1. drop ``/Evaluation/`` -> retry
+ 2. drop leading ``Evaluation/`` from the raw component if EVAL_BASE_PREFIX_OVERRIDE
+ 3. take basename + walk under override prefix (slow last resort)
+ """
+ if os.path.isfile(video_path):
+ return video_path
+
+ candidates = []
+
+ # (1) Drop the spurious "/Evaluation/" component anywhere in the path.
+ if "/Evaluation/" in video_path:
+ candidates.append(video_path.replace("/Evaluation/", "/", 1))
+
+ # (2) Override-prefix-based rewrites.
+ override = os.getenv("EVAL_BASE_PREFIX_OVERRIDE", "").rstrip("/")
+ base_name = os.path.basename(video_path)
+ parent = os.path.dirname(video_path)
+ if override:
+ rel = video_path
+ for prefix in ("Evaluation/", "evaluation/", "./Evaluation/", "./"):
+ if rel.startswith(prefix):
+ rel = rel[len(prefix):]
+ break
+ candidates.append(os.path.join(override, rel))
+ candidates.append(os.path.join(override, base_name))
+ pdir = os.path.basename(parent)
+ if pdir:
+ candidates.append(os.path.join(override, pdir, base_name))
+
+ for c in candidates:
+ if os.path.isfile(c):
+ print(f"[qwenvl_decord_patch] path-resolved {video_path!r} → {c!r}",
+ file=sys.stderr)
+ return c
+
+ # (3) Last resort: walk under override (or the path's grandparent).
+ walk_root = override or os.path.dirname(parent) or parent
+ if walk_root and os.path.isdir(walk_root):
+ for root, dirs, files in os.walk(walk_root):
+ if base_name in files:
+ hit = os.path.join(root, base_name)
+ print(f"[qwenvl_decord_patch] walked-resolved {video_path!r} → {hit!r}",
+ file=sys.stderr)
+ return hit
+ depth = root[len(walk_root):].count(os.sep)
+ if depth >= 4:
+ dirs.clear()
+
+ return video_path
+
+
+def _decord_fetch_video_new_api(ele: dict, *args, **kwargs):
+ """Drop-in replacement for ``qwen_vl_utils.vision_process.fetch_video``.
+
+ The exact signature of fetch_video varies across qwen_vl_utils
+ versions, but we observe the following calling conventions in the
+ wild:
+
+ v0.0.8 : fetch_video(ele, image_factor) → tensor
+ v0.0.10+ : fetch_video(ele, image_factor=…,
+ return_video_sample_fps=True,
+ return_video_metadata=False) → (video, sample_fps)
+ v0.0.10+ : fetch_video(ele, …, return_video_metadata=True) → (video, metadata, sample_fps)
+
+ We therefore inspect ``return_video_sample_fps`` /
+ ``return_video_metadata`` kwargs and return whichever shape the
+ caller asked for. The actual decode is always decord-based.
+ """
+ import torch
+ import decord
+
+ video_path = ele["video"]
+ if isinstance(video_path, str) and video_path.startswith("file://"):
+ video_path = video_path[len("file://"):]
+
+ # Multi-tier fallback path resolution (see _resolve_video_path docstring)
+ video_path = _resolve_video_path(video_path)
+
+ if "video_start" in ele or "video_end" in ele:
+ # Trim window. decord supports ranges via numeric indices, but no
+ # evaluated benchmark sets these, so we keep it simple.
+ raise NotImplementedError(
+ "video_start/video_end not implemented in decord patch; "
+ "the evaluated benchmarks do not need this."
+ )
+
+ vr = decord.VideoReader(video_path)
+ total_frames = len(vr)
+ video_fps = float(vr.get_avg_fps())
+ if total_frames <= 0 or not math.isfinite(video_fps) or video_fps <= 0:
+ raise RuntimeError(
+ f"decord-patch: invalid video {video_path!r} "
+ f"(total_frames={total_frames}, video_fps={video_fps})"
+ )
+
+ nframes = _smart_nframes(ele, total_frames=total_frames, video_fps=video_fps)
+ idx = torch.linspace(0, total_frames - 1, nframes).round().long().tolist()
+ arr = vr.get_batch(idx).asnumpy() # (T, H, W, C)
+ video = torch.from_numpy(arr).permute(0, 3, 1, 2).contiguous() # (T, C, H, W)
+
+ sample_fps = nframes / (total_frames / video_fps) if total_frames > 0 else float(video_fps)
+
+ # metadata fields are chosen to be compatible with transformers'
+ # VideoMetadata dataclass (qwen3-vl image processor instantiates this
+ # from the dict we return). Field set across qwen2/qwen3-vl versions:
+ # fps, total_num_frames, duration, frames_indices, video_backend
+ # We deliberately DO NOT include "video_fps" — that name causes
+ # ``VideoMetadata.__init__() got an unexpected keyword argument 'video_fps'``
+ # on newer transformers.
+ metadata = {
+ "fps": float(video_fps),
+ "total_num_frames": int(total_frames),
+ "duration": float(total_frames / video_fps),
+ "frames_indices": [int(i) for i in idx],
+ "video_backend": "decord",
+ }
+
+ # Decide return shape based on what the caller asked for.
+ # NOTE on shapes: qwen_vl_utils versions disagree.
+ # * Some versions: fetch_video(...) → (video, metadata, sample_fps)
+ # * Other versions: fetch_video(...) → ((video, metadata), sample_fps)
+ # (i.e. the wrapper bundles the metadata INSIDE the
+ # video return so that process_vision_info can keep
+ # a 2-tuple unpack.)
+ # The pinned qwen_vl_utils release uses the 2-tuple-with-bundled-metadata
+ # variant, so we default to that when both flags are set. Override with
+ # QWENVL_RETURN_SHAPE=3tuple if your stack expects the 3-element form.
+ want_metadata = bool(kwargs.get("return_video_metadata", False))
+ want_sample_fps = bool(kwargs.get("return_video_sample_fps", False))
+ return_shape = os.getenv("QWENVL_RETURN_SHAPE", "auto").strip().lower()
+
+ if want_metadata and want_sample_fps:
+ if return_shape == "3tuple":
+ return video, metadata, float(sample_fps)
+ # default "auto" / "bundled" → 2-tuple form
+ return (video, metadata), float(sample_fps)
+ if want_metadata:
+ return video, metadata
+ if want_sample_fps:
+ return video, float(sample_fps)
+ return video
+
+
+def _apply_patch() -> None:
+ if os.getenv("DISABLE_QWENVL_DECORD_PATCH", "0") == "1":
+ print("[qwenvl_decord_patch] disabled via env", file=sys.stderr)
+ return
+
+ try:
+ from qwen_vl_utils import vision_process as vp
+ except Exception as exc: # pragma: no cover
+ print(f"[qwenvl_decord_patch] qwen_vl_utils import failed: {exc}",
+ file=sys.stderr)
+ return
+
+ # Make sure decord is importable up front so we fail loudly here, not
+ # mid-eval, if it's not installed.
+ try:
+ import decord # noqa: F401
+ except Exception as exc:
+ print(f"[qwenvl_decord_patch] decord not installed: {exc}",
+ file=sys.stderr)
+ print(" Install with: pip install decord",
+ file=sys.stderr)
+ return
+
+ # Belt: try the env-based switch (no-op on new versions).
+ os.environ.setdefault("FORCE_QWENVL_VIDEO_READER", "decord")
+
+ # Suspenders: replace fetch_video itself.
+ if not hasattr(vp, "fetch_video"):
+ print("[qwenvl_decord_patch] vp.fetch_video missing, abort",
+ file=sys.stderr)
+ return
+
+ # Also alias the torchvision backend to our decord function in case any
+ # downstream code re-reads VIDEO_READER_BACKENDS directly.
+ backends = getattr(vp, "VIDEO_READER_BACKENDS", None)
+ if isinstance(backends, dict):
+ # Wrap to satisfy the SINGLE-tensor return convention used by some
+ # internal callers (the older ``_read_video_torchvision`` returns
+ # just a tensor in 0.0.8 path).
+ def _single_tensor_decord(ele):
+ v, _meta, _fps = _decord_fetch_video_new_api(ele)
+ return v
+ backends["torchvision"] = _single_tensor_decord
+
+ vp.fetch_video = _decord_fetch_video_new_api
+
+ print(
+ f"[qwenvl_decord_patch] applied. "
+ f"vp.fetch_video → decord (return shape auto-adapts to "
+ f"return_video_metadata / return_video_sample_fps kwargs; "
+ f"override with QWENVL_RETURN_SHAPE=3tuple). "
+ f"file={vp.__file__}",
+ file=sys.stderr,
+ )
+
+
+_apply_patch()
diff --git a/code/eval/task/revsi/eval_revsi_vllm.py b/code/eval/task/revsi/eval_revsi_vllm.py
new file mode 100644
index 0000000000000000000000000000000000000000..17db339a99e80c611dda6fc97c41fa70932ec3a7
--- /dev/null
+++ b/code/eval/task/revsi/eval_revsi_vllm.py
@@ -0,0 +1,1025 @@
+"""ReVSI evaluation with vLLM data-parallel sharding.
+
+This keeps the existing OraRL VSI execution style (one vLLM process per GPU,
+JSONL shards, centralized merge) while using ReVSI's official prompt,
+question-type grouping, and macro-averaged metrics.
+
+Metrics:
+ * numerical tasks: MRA (mean relative accuracy)
+ * multiple-choice tasks: exact-match ACC on the first token/letter
+
+Outputs one JSONL shard per process and a summary JSON after launcher merge.
+"""
+
+import argparse
+import json
+import os
+import re
+import sys
+import time
+from collections import defaultdict
+from functools import lru_cache
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+
+import numpy as np
+
+
+TASK_DIR = Path(__file__).resolve().parents[1]
+if str(TASK_DIR) not in sys.path:
+ sys.path.insert(0, str(TASK_DIR))
+
+from canonical_data import load_json_records # noqa: E402
+
+
+try:
+ from tqdm import tqdm
+except ImportError:
+ def tqdm(iterable, **_kwargs):
+ return iterable
+
+
+NUMERICAL_TASKS = {
+ "object_abs_distance",
+ "object_counting_single",
+ "object_counting_multiple",
+ "object_size_estimation",
+ "room_size_estimation_single",
+ "room_size_estimation_multiple",
+}
+
+MC_TASKS = {
+ "object_rel_direction_forward_easy",
+ "object_rel_direction_backward_easy",
+ "object_rel_direction_forward_hard",
+ "object_rel_direction_backward_hard",
+ "object_rel_distance_closest",
+ "object_rel_distance_farthest",
+ "route_planning",
+}
+
+DIRECTION_PREFIXES = ("object_rel_direction",)
+
+REPORT_ORDER = [
+ ("Abs. Dist", "object_abs_distance", "MRA"),
+ ("Obj. Count", "object_counting", "MRA"),
+ ("Rel. Dir", "object_rel_direction", "ACC"),
+ ("Rel. Dis", "object_rel_distance", "ACC"),
+ ("Obj. Size", "object_size_estimation", "MRA"),
+ ("Room Size", "room_size_estimation", "MRA"),
+ ("Route Plan", "route_planning", "ACC"),
+]
+
+COMPOSITE_METRICS = {
+ "object_counting": (
+ "object_counting_single",
+ "object_counting_multiple",
+ ),
+ "object_rel_direction": (
+ "object_rel_direction_forward_easy",
+ "object_rel_direction_backward_easy",
+ "object_rel_direction_forward_hard",
+ "object_rel_direction_backward_hard",
+ ),
+ "object_rel_distance": (
+ "object_rel_distance_closest",
+ "object_rel_distance_farthest",
+ ),
+ "room_size_estimation": (
+ "room_size_estimation_single",
+ "room_size_estimation_multiple",
+ ),
+}
+
+ANSWER_RE = re.compile(r"\s*(.*?)\s* ", flags=re.DOTALL | re.IGNORECASE)
+FINAL_ANSWER_RE = re.compile(
+ r"(?:the\s+)?(?:final\s+answer|answer|答案)\s*(?:is|=|是|为)?\s*[::]?\s*"
+ r"([-+]?\d+(?:\.\d+)?|[A-Da-d])\b",
+ flags=re.IGNORECASE,
+)
+LOOSE_NUMERIC_INSTRUCTION_RE = re.compile(
+ r"\s*Please answer the question using a single word or phrase\.?\s*",
+ flags=re.IGNORECASE,
+)
+NUMERIC_STRICT_INSTRUCTION = (
+ "Output only a number. Do not output option letters, units, explanations, "
+ "or punctuation."
+)
+MC_STRICT_INSTRUCTION = "Answer with the option letter only."
+
+
+def _strip_answer_tags(text: str) -> str:
+ text = text or ""
+ matches = ANSWER_RE.findall(text)
+ if matches:
+ return matches[-1].strip()
+ return text.strip()
+
+
+def strip_think_block(text: str) -> str:
+ text = (text or "").strip()
+ if not text:
+ return ""
+ # Prefer content after the last closing tag. This handles common Qwen
+ # no-think outputs such as " B" and complete think blocks.
+ parts = re.split(r"", text, flags=re.IGNORECASE)
+ if len(parts) > 1:
+ text = parts[-1]
+ else:
+ text = re.sub(r".*? ", "", text, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r"?think>", "", text, flags=re.IGNORECASE)
+ return text.strip()
+
+
+def fuzzy_matching(pred: str) -> str:
+ pred = _strip_answer_tags(strip_think_block(pred))
+ pred = pred.strip()
+ if not pred:
+ return ""
+ m = FINAL_ANSWER_RE.search(pred)
+ if m:
+ return m.group(1).rstrip(".").strip()
+ # Official VSI code uses the first whitespace-separated token and strips
+ # trailing punctuation. After stripping dangling think tags, this recovers
+ # outputs like " B" -> "B" and " 2" -> "2".
+ return pred.split()[0].rstrip(".。,::").strip()
+
+
+def extract_numeric_prediction(pred: str) -> str:
+ """Robust numeric extraction for VSI numerical tasks.
+
+ Some RL checkpoints emit option-like prefixes on numeric answers, e.g.
+ "A.2" or "A\n\n4". For numerical tasks, recover the number while keeping
+ MC parsing unchanged.
+ """
+ text = _strip_answer_tags(strip_think_block(pred)).strip()
+ if not text:
+ return ""
+ m = FINAL_ANSWER_RE.search(text)
+ if m and re.search(r"\d", m.group(1)):
+ return m.group(1).rstrip(".").strip()
+ m = re.match(r"^\s*[A-Da-d]\s*[\.\)::\-]?\s*([-+]?\d+(?:\.\d+)?)\b", text)
+ if m:
+ return m.group(1).strip()
+ m = re.search(r"[-+]?\d+(?:\.\d+)?", text)
+ return m.group(0).strip() if m else fuzzy_matching(pred)
+
+
+def clean_prediction_for_task(question_type: str, pred: str) -> str:
+ if question_type in NUMERICAL_TASKS:
+ return extract_numeric_prediction(pred)
+ return fuzzy_matching(pred)
+
+
+def to_float(pred: Any) -> Optional[float]:
+ try:
+ return float(str(pred).strip())
+ except (ValueError, TypeError):
+ return None
+
+
+def exact_match(pred: str, target: Any) -> float:
+ return 1.0 if str(pred).strip().lower() == str(target).strip().lower() else 0.0
+
+
+def abs_dist_norm(pred: float, target: float) -> float:
+ if target == 0:
+ return float("inf")
+ return abs(pred - target) / abs(target)
+
+
+def mean_relative_accuracy(pred: float, target: float,
+ start: float = 0.5,
+ end: float = 0.95,
+ interval: float = 0.05) -> float:
+ num_pts = (end - start) / interval + 2
+ conf_intervs = np.linspace(start, end, int(num_pts))
+ accuracy = abs_dist_norm(pred, target) <= 1 - conf_intervs
+ return float(accuracy.mean())
+
+
+def is_direction_task(question_type: str) -> bool:
+ return any(question_type.startswith(p) for p in DIRECTION_PREFIXES)
+
+
+def report_key(question_type: str) -> str:
+ for key, members in COMPOSITE_METRICS.items():
+ if question_type in members:
+ return key
+ return question_type
+
+
+def strengthen_prompt(prompt: str, question_type: str) -> str:
+ prompt = str(prompt or "").strip()
+ if question_type in NUMERICAL_TASKS:
+ if NUMERIC_STRICT_INSTRUCTION in prompt:
+ return prompt
+ prompt = LOOSE_NUMERIC_INSTRUCTION_RE.sub("\n", prompt).strip()
+ return prompt.rstrip() + "\n" + NUMERIC_STRICT_INSTRUCTION
+ if question_type in MC_TASKS or is_direction_task(question_type):
+ lower = prompt.lower()
+ if "option" in lower and "letter" in lower:
+ return prompt
+ return prompt.rstrip() + "\n" + MC_STRICT_INSTRUCTION
+ return prompt
+
+
+def compute_sample_score(question_type: str, prediction: str,
+ ground_truth: Any) -> Tuple[str, float]:
+ cleaned = clean_prediction_for_task(question_type, prediction)
+ if question_type in NUMERICAL_TASKS:
+ pred_val = to_float(cleaned)
+ gt_val = to_float(ground_truth)
+ if pred_val is None or gt_val is None or gt_val == 0:
+ return "MRA", 0.0
+ return "MRA", mean_relative_accuracy(pred_val, gt_val)
+ if question_type in MC_TASKS or is_direction_task(question_type):
+ return "ACC", exact_match(cleaned, ground_truth)
+ return "UNK", 0.0
+
+
+def load_rows(path: str) -> List[Dict[str, Any]]:
+ if path.endswith(".parquet"):
+ import pandas as pd
+
+ return pd.read_parquet(path).to_dict(orient="records")
+ return load_json_records(path)
+
+
+def get_image_list(item: Dict[str, Any]) -> List[str]:
+ imgs = item.get("image_list") or item.get("images") or item.get("image") or []
+ if isinstance(imgs, str):
+ imgs = [imgs]
+ return [str(p) for p in imgs if p]
+
+
+def get_video_list(item: Dict[str, Any]) -> List[str]:
+ vids = item.get("videos") or item.get("video") or []
+ if isinstance(vids, str):
+ vids = [vids]
+ return [str(p) for p in vids if p]
+
+
+def infer_video_path(item: Dict[str, Any], video_root: str = "") -> str:
+ """Resolve ``ReVSI/_frame/.mp4``."""
+ vids = get_video_list(item)
+ if vids:
+ p = vids[0]
+ return os.path.join(video_root, p) if video_root and not os.path.isabs(p) else p
+
+ raw = item.get("raw") if isinstance(item.get("raw"), dict) else item
+ scene = str(
+ item.get("scene_id")
+ or item.get("scene_name")
+ or raw.get("scene_id")
+ or raw.get("scene_name")
+ or ""
+ ).strip()
+ if not scene:
+ return ""
+ frame_budget = str(item.get("num_frames") or raw.get("num_frames") or "").strip()
+ frame_budget = re.sub(r"[_-]?frame$", "", frame_budget, flags=re.IGNORECASE)
+ if not frame_budget:
+ return ""
+ filename = scene if scene.endswith(".mp4") else f"{scene}.mp4"
+ relative = os.path.join(f"{frame_budget}_frame", filename)
+ return os.path.join(video_root, relative) if video_root else relative
+
+
+def get_item_id(item: Dict[str, Any], idx: int) -> str:
+ for key in ("id", "item_id"):
+ if item.get(key) is not None:
+ return str(item[key])
+ return f"idx_{idx}"
+
+
+# ---------------------------------------------------------------------------
+# Train / Eval prompt alignment.
+#
+# We rewrite the eval prompts so they match the *new training* prompts
+# byte-for-byte. The training prompts were rewritten by
+# scripts/normalize_sft_prompts.py to:
+#
+# - drop the verbose "Please answer the question using a single word or
+# phrase." instruction
+# - use a concise, format-anchored sentence on numeric tasks:
+# abs_distance : "...? Answer with a number in meters (e.g. 2.3)."
+# counting : "...? Answer with an integer (e.g. 3)."
+# size : "...? Answer with a number (e.g. 120)."
+# room_size : "...? Answer with a number (e.g. 25.5)."
+# - simplify size: "the length of the longest dimension (length, width, or
+# height) of X, measured in centimeters" -> "the longest dimension
+# (length, width, or height) of X in centimeters"
+# - normalise rel_distance to the "(a, b, c, d)" + "If there are multiple
+# instances ..." form (matches VSI-Bench eval).
+# - keep route_planning steps on separate lines.
+#
+# All rewrites are idempotent.
+# ---------------------------------------------------------------------------
+
+# ----- numeric tasks --------------------------------------------------------
+
+_NUM_TAIL_OLD = re.compile(
+ r"\s*Please answer the question using a single word or phrase\.\s*$",
+ flags=re.IGNORECASE,
+)
+
+# abs_distance
+_ABS_DIST_NEW_TAIL = (
+ "? Answer with a number in meters within ... tags. "
+ "e.g. 2.3 "
+)
+_ABS_DIST_RE = re.compile(
+ r"(Measuring from the closest point of each object, what is the )"
+ r"(?:direct )?(distance between the [^?]+? and the [^?]+?)"
+ r"(?:\s*\(in meters\))?\?",
+ flags=re.IGNORECASE,
+)
+
+# counting
+_COUNTING_NEW_TAIL = (
+ "? Answer with an integer within ... tags. "
+ "e.g. 3 "
+)
+_COUNTING_RE = re.compile(
+ r"(How many [^?]+ are in this room)\?",
+ flags=re.IGNORECASE,
+)
+
+# size
+_SIZE_LONG_RE = re.compile(
+ r"What is the length of the longest dimension \(length, width, or height\) "
+ r"of (?Pthe [^,?]+?), measured in centimeters\?",
+ flags=re.IGNORECASE,
+)
+# Short form already used in training-rewritten samples.
+_SIZE_SHORT_RE = re.compile(
+ r"What is the longest dimension \(length, width, or height\) "
+ r"of (?Pthe [^?]+?) in centimeters\?",
+ flags=re.IGNORECASE,
+)
+_SIZE_NEW_TAIL = (
+ " Answer with a number in centimeters within ... tags. "
+ "e.g. 120 "
+)
+
+# room_size
+_ROOM_SIZE_NEW = (
+ "What is the size of this room in square meters? "
+ "If multiple rooms are shown, estimate the combined size. "
+ "Answer with a number in square meters within ... tags. "
+ "e.g. 25.5 "
+)
+_ROOM_SIZE_OLD_RE = re.compile(
+ r"What is the size of this room \(in square meters\)\?"
+ r"[ \t]*\n?[ \t]*"
+ r"(?:If multiple rooms are shown, estimate the size of the combined space\.)?",
+ flags=re.IGNORECASE,
+)
+
+# Generic detector for "already aligned with new tail" so all 4 numeric
+# rewriters short-circuit.
+_HAS_NEW_ANSWER_TAG_HINT_RE = re.compile(
+ r"\.\.\. \s*tags",
+ flags=re.IGNORECASE,
+)
+
+# MC: rewrite legacy direct-answer tails to the joint-SFT answer-only format.
+_MC_OLD_TAIL_RE = re.compile(
+ r"\s*(?:"
+ r"Answer with the option'?s letter from the given choices directly\.?|"
+ r"Answer with the option letter within \.\.\. tags\.?\s*(?:e\.g\.|Example:)?\s*A |"
+ r"Output only the option letter inside \.\.\. \.?\s*Do not explain\.?|"
+ r"Choose the best answer from the options\. Put exactly one uppercase option letter inside \.\.\. \s*Do not explain\. Example: A "
+ r")\s*$",
+ flags=re.IGNORECASE,
+)
+_MC_NEW_TAIL = (
+ "Choose the best answer from the options. "
+ "Put exactly one uppercase option letter inside ... "
+ "Do not explain. Example: A "
+)
+
+# ----- rel_distance / route_planning ----------------------------------------
+
+_REL_DIST_TRIGGER_RE = re.compile(
+ r"which\s+of\s+these\s+objects\s*\([^)]*\)\s+is\s+the\s+closest\s+to\s+",
+ flags=re.IGNORECASE,
+)
+_REL_DIST_INSERT_RE = re.compile(
+ r"(\?)(\s*\n?\s*)(Options\s*:)", flags=re.IGNORECASE
+)
+_REL_DIST_MULTI_INSTANCE_LINE = (
+ "If there are multiple instances of an object category, measure to the closest."
+)
+
+_ROUTE_BLOCK_RE = re.compile(
+ r"(turn right\.'\)\s*:)\s*(.*?)(\s*You have reached the final destination\.)",
+ flags=re.IGNORECASE | re.DOTALL,
+)
+_ROUTE_STEP_FINDALL_RE = re.compile(r"\d+\.\s+\S")
+
+
+def _fix_route_planning(prompt: str) -> str:
+ def repl(m):
+ head, body, tail = m.group(1), m.group(2), m.group(3)
+ positions = [mt.start() for mt in _ROUTE_STEP_FINDALL_RE.finditer(body)]
+ if not positions:
+ return m.group(0)
+ positions.append(len(body))
+ parts = [body[positions[i]:positions[i + 1]].strip()
+ for i in range(len(positions) - 1)]
+ parts = [p for p in parts if p]
+ if not parts:
+ return m.group(0)
+ return head + "\n" + "\n".join(parts) + "\n" + tail.lstrip()
+
+ return _ROUTE_BLOCK_RE.sub(repl, prompt, count=1)
+
+
+def _rewrite_abs_distance(prompt: str) -> str:
+ """Rewrite to "...what is the direct distance between X and Y?
+ Answer with a number in meters within ... tags. ..."."""
+ if "Answer with a number in meters within " in prompt:
+ return prompt
+ # Strip any older tails (legacy "Please answer ..." or the previous
+ # format-anchored "e.g. 2.3" form).
+ prompt = _NUM_TAIL_OLD.sub("", prompt)
+ prompt = re.sub(r"\s*Answer with a number in meters \(e\.g\.[^)]*\)\.\s*$",
+ "", prompt, flags=re.IGNORECASE)
+ new_prompt = _ABS_DIST_RE.sub(
+ lambda m: m.group(1) + "direct " + m.group(2) + _ABS_DIST_NEW_TAIL,
+ prompt,
+ count=1,
+ )
+ return new_prompt
+
+
+def _rewrite_counting(prompt: str) -> str:
+ if "Answer with an integer within " in prompt:
+ return prompt
+ prompt = _NUM_TAIL_OLD.sub("", prompt)
+ prompt = re.sub(r"\s*Answer with an integer \(e\.g\.[^)]*\)\.\s*$",
+ "", prompt, flags=re.IGNORECASE)
+ return _COUNTING_RE.sub(lambda m: m.group(1) + _COUNTING_NEW_TAIL,
+ prompt, count=1)
+
+
+def _rewrite_size(prompt: str) -> str:
+ if "Answer with a number in centimeters within " in prompt:
+ return prompt
+ prompt = _NUM_TAIL_OLD.sub("", prompt)
+ prompt = re.sub(r"\s*Answer with a number \(e\.g\.[^)]*\)\.\s*$",
+ "", prompt, flags=re.IGNORECASE)
+ # Long form -> new short form with anchor.
+ new_prompt, n = _SIZE_LONG_RE.subn(
+ lambda m: ("What is the longest dimension (length, width, or height) "
+ "of " + m.group("obj") + " in centimeters?" + _SIZE_NEW_TAIL),
+ prompt,
+ count=1,
+ )
+ if n:
+ return new_prompt
+ # Short form (no "measured in centimeters") -> just add the anchor.
+ return _SIZE_SHORT_RE.sub(
+ lambda m: ("What is the longest dimension (length, width, or height) "
+ "of " + m.group("obj") + " in centimeters?" + _SIZE_NEW_TAIL),
+ prompt,
+ count=1,
+ )
+
+
+def _rewrite_room_size(prompt: str) -> str:
+ if "Answer with a number in square meters within " in prompt:
+ return prompt
+ prompt = _NUM_TAIL_OLD.sub("", prompt)
+ prompt = re.sub(r"\s*Answer with a number \(e\.g\.[^)]*\)\.\s*$",
+ "", prompt, flags=re.IGNORECASE)
+ return _ROOM_SIZE_OLD_RE.sub(_ROOM_SIZE_NEW, prompt, count=1)
+
+
+def _rewrite_mc_tail(prompt: str) -> str:
+ """Replace "Answer with the option's letter from the given choices
+ directly." -> the new -tagged version."""
+ if _MC_NEW_TAIL in prompt:
+ return prompt
+ if _MC_OLD_TAIL_RE.search(prompt):
+ return _MC_OLD_TAIL_RE.sub("\n" + _MC_NEW_TAIL, prompt, count=1)
+ return prompt
+
+
+def align_prompt_to_training(question_type: str, prompt: str) -> str:
+ """Rewrite eval prompts to match the (new) training phrasing.
+
+ Idempotent: applying twice yields the same result.
+ """
+ if not prompt:
+ return prompt
+
+ if question_type == "object_abs_distance":
+ return _rewrite_abs_distance(prompt)
+
+ if question_type == "object_counting":
+ return _rewrite_counting(prompt)
+
+ if question_type == "object_size_estimation":
+ return _rewrite_size(prompt)
+
+ if question_type == "room_size_estimation":
+ return _rewrite_room_size(prompt)
+
+ if question_type == "object_rel_distance":
+ if _REL_DIST_TRIGGER_RE.search(prompt) and (
+ _REL_DIST_MULTI_INSTANCE_LINE not in prompt
+ ):
+ prompt = _REL_DIST_INSERT_RE.sub(
+ lambda m: m.group(1) + "\n" + _REL_DIST_MULTI_INSTANCE_LINE
+ + "\n" + m.group(3),
+ prompt,
+ count=1,
+ )
+ return _rewrite_mc_tail(prompt)
+
+ if question_type == "route_planning":
+ if "turn right.')" in prompt.lower() and "\n1." not in prompt:
+ prompt = _fix_route_planning(prompt)
+ return _rewrite_mc_tail(prompt)
+
+ # All other MC tasks (rel_direction_*, obj_appearance_order) only need
+ # the tail rewrite.
+ return _rewrite_mc_tail(prompt)
+
+
+# ---------------------------------------------------------------------------
+# Optional strict-numeric instruction (path A diagnostic).
+#
+# Background: a long SFT on the 87k mixture (numeric ~17k vs MC/letter ~70k)
+# pushes the model to emit single letters (mostly "B") even on numeric tasks,
+# despite the training prompt's "Please answer ... single word or phrase."
+# This helper appends an *additional* sentence that explicitly forbids
+# letters, so we can A/B test whether the model still has numeric capability
+# but is just being triggered into MC-mode.
+#
+# Toggled via the --strict_numeric_prompt CLI flag (default OFF).
+# ---------------------------------------------------------------------------
+STRICT_NUMERIC_SUFFIX = (
+ "Output ONLY a single integer or decimal number (no units, no letters, no "
+ "punctuation, no explanation)."
+)
+
+
+def apply_strict_numeric_suffix(prompt: str) -> str:
+ if not prompt:
+ return prompt
+ if STRICT_NUMERIC_SUFFIX in prompt:
+ return prompt
+ return prompt.rstrip() + "\n" + STRICT_NUMERIC_SUFFIX
+
+
+def normalise_item(item: Dict[str, Any], idx: int,
+ strict_numeric: bool = False,
+ video_root: str = "") -> Dict[str, Any]:
+ question = str(item.get("question") or "").strip()
+ gt = item.get("ground_truth")
+ qtype = str(item.get("question_type") or "unknown")
+ raw_options = item.get("options")
+ if raw_options is None:
+ options = []
+ elif isinstance(raw_options, np.ndarray):
+ options = raw_options.tolist()
+ elif isinstance(raw_options, (list, tuple)):
+ options = list(raw_options)
+ else:
+ options = [raw_options]
+ options = [str(option) for option in options if option is not None]
+
+ prompt_parts = ["These are frames of a video.", question]
+ if qtype in MC_TASKS:
+ prompt_parts.append("Options:\n" + "\n".join(options))
+ prompt_parts.append(
+ "Answer with the option's letter from the given choices directly."
+ )
+ elif qtype in NUMERICAL_TASKS:
+ prompt_parts.append(
+ "Answer the question using a single integer or decimal number."
+ )
+ prompt = "\n".join(part for part in prompt_parts if part).strip()
+ if strict_numeric and qtype in NUMERICAL_TASKS:
+ prompt = apply_strict_numeric_suffix(prompt)
+
+ scene_id = str(item.get("scene_id") or "").strip()
+ out = {
+ "id": get_item_id(item, idx),
+ "dataset": item.get("dataset", "revsi"),
+ "scene_id": scene_id,
+ "scene_name": scene_id,
+ "num_frames": str(item.get("num_frames") or "").strip(),
+ "question_type": qtype,
+ "question": question,
+ "prompt": prompt,
+ "ground_truth": "" if gt is None else str(gt),
+ "options": options,
+ "image_list": get_image_list(item),
+ "videos": get_video_list(item),
+ "raw": item,
+ }
+ out["video_path"] = infer_video_path(out, video_root)
+ return out
+
+
+def build_conversation(item: Dict[str, Any]) -> List[Dict[str, Any]]:
+ content = []
+ for img_path in item["image_list"]:
+ content.append({
+ "type": "image_url",
+ "image_url": {"url": f"file://{img_path}"},
+ })
+ content.append({"type": "text", "text": item["prompt"]})
+ return [
+ {"role": "system", "content": "You are a helpful assistant."},
+ {"role": "user", "content": content},
+ ]
+
+
+@lru_cache(maxsize=4096)
+def get_video_frame_count(path: str) -> int:
+ """Read frame-count metadata without decoding the full video."""
+
+ from decord import VideoReader, cpu
+
+ return len(VideoReader(path, ctx=cpu(0), num_threads=1))
+
+
+def build_video_messages(item: Dict[str, Any], args) -> List[Dict[str, Any]]:
+ video_item: Dict[str, Any] = {
+ "type": "video",
+ "video": item["video_path"],
+ "total_pixels": args.video_total_pixels,
+ }
+ if args.exact_nframes:
+ total_frames = get_video_frame_count(item["video_path"])
+ # qwen_vl_utils requires nframes <= total_frames and a multiple of two.
+ # ReVSI all-frame videos are not uniformly long enough for a literal
+ # 128-frame request, so short videos use every available even frame.
+ nframes = min(args.max_frames, total_frames)
+ nframes -= nframes % 2
+ if nframes < 2:
+ raise ValueError(
+ f"Video has fewer than two usable frames: {item['video_path']}"
+ )
+ video_item["nframes"] = nframes
+ else:
+ video_item["max_frames"] = args.max_frames
+ video_item["fps"] = args.fps
+ if args.video_min_pixels is not None:
+ video_item["min_pixels"] = args.video_min_pixels
+ if args.video_max_pixels is not None:
+ video_item["max_pixels"] = args.video_max_pixels
+ return [
+ {"role": "system", "content": "You are a helpful assistant."},
+ {"role": "user", "content": [video_item, {"type": "text", "text": item["prompt"]}]},
+ ]
+
+
+def prepare_video_for_vllm(messages: List[Dict[str, Any]], processor, patch_size: int,
+ enable_thinking: bool = False) -> Dict[str, Any]:
+ """Client-side video decoding path for vLLM.
+
+ Mirrors existing STVG/Timelens eval: apply chat template, decode video via
+ qwen_vl_utils, then pass multi_modal_data to vLLM.generate.
+ """
+ from qwen_vl_utils import process_vision_info
+
+ text = processor.apply_chat_template(
+ messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=enable_thinking,
+ )
+ _images, video_inputs, video_kwargs = process_vision_info(
+ messages,
+ image_patch_size=patch_size,
+ return_video_kwargs=True,
+ return_video_metadata=True,
+ )
+ video_kwargs = video_kwargs or {}
+ video_kwargs["do_resize"] = False
+
+ llm_input: Dict[str, Any] = {"prompt": text}
+ if video_inputs:
+ llm_input["multi_modal_data"] = {"video": video_inputs}
+ llm_input["mm_processor_kwargs"] = video_kwargs
+ return llm_input
+
+
+def summarise(records: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
+ qtype_scores = defaultdict(list)
+ for r in records:
+ qtype = r.get("question_type", "unknown")
+ score = float(r.get("score", 0.0) or 0.0)
+ qtype_scores[qtype].append(score)
+
+ task_scores = {}
+ task_means = {}
+ for display, key, metric in REPORT_ORDER:
+ members = COMPOSITE_METRICS.get(key, (key,))
+ member_scores = [
+ float(np.mean(qtype_scores[member]))
+ for member in members
+ if qtype_scores.get(member)
+ ]
+ if member_scores:
+ task_mean = float(np.mean(member_scores))
+ task_means[key] = task_mean
+ task_scores[key] = {
+ "display": display,
+ "metric": metric,
+ # ReVSI first averages each fine-grained question type, then
+ # macro-averages related types into the reported category.
+ "score": round(task_mean * 100, 2),
+ "count": sum(len(qtype_scores.get(member, [])) for member in members),
+ }
+
+ num_keys = {"object_counting", "object_abs_distance", "object_size_estimation", "room_size_estimation"}
+ mc_keys = {"object_rel_distance", "object_rel_direction", "route_planning"}
+ num_vals = [value for key, value in task_means.items() if key in num_keys]
+ mc_vals = [value for key, value in task_means.items() if key in mc_keys]
+ all_vals = list(task_means.values())
+
+ out = {
+ "total": sum(len(v) for v in qtype_scores.values()),
+ "task_scores": task_scores,
+ "question_type_scores": {
+ k: {"score": round(float(np.mean(v) * 100), 2), "count": len(v)}
+ for k, v in sorted(qtype_scores.items())
+ },
+ }
+ if num_vals:
+ out["numerical_avg"] = round(float(np.mean(num_vals) * 100), 2)
+ if mc_vals:
+ out["mc_avg"] = round(float(np.mean(mc_vals) * 100), 2)
+ if all_vals:
+ out["overall_avg"] = round(float(np.mean(all_vals) * 100), 2)
+ return out
+
+
+def print_summary(summary: Dict[str, Any], prefix: str = "") -> None:
+ print(prefix + "=" * 58, flush=True)
+ print(prefix + f"ReVSI Results (samples={summary.get('total', 0)})", flush=True)
+ print(prefix + "=" * 58, flush=True)
+ print(prefix + f"{'Task':<16} {'Metric':<6} {'Score':>8} {'Count':>7}", flush=True)
+ print(prefix + "-" * 42, flush=True)
+ for _display, key, _metric in REPORT_ORDER:
+ if key not in summary.get("task_scores", {}):
+ continue
+ s = summary["task_scores"][key]
+ print(prefix + f"{s['display']:<16} {s['metric']:<6} {s['score']:>7.2f}% {s['count']:>7}", flush=True)
+ print(prefix + "-" * 42, flush=True)
+ if "numerical_avg" in summary:
+ print(prefix + f"{'Numerical Avg':<16} {'MRA':<6} {summary['numerical_avg']:>7.2f}%", flush=True)
+ if "mc_avg" in summary:
+ print(prefix + f"{'MC Avg':<16} {'ACC':<6} {summary['mc_avg']:>7.2f}%", flush=True)
+ if "overall_avg" in summary:
+ print(prefix + f"{'Overall Avg':<16} {'---':<6} {summary['overall_avg']:>7.2f}%", flush=True)
+ print(prefix + "=" * 58, flush=True)
+
+
+def parse_args():
+ p = argparse.ArgumentParser(description="ReVSI evaluation via vLLM")
+ p.add_argument("--model_path", required=True)
+ p.add_argument("--qa_file", required=True)
+ p.add_argument("--output_json_path", required=True)
+ p.add_argument("--resume_dir", default="", help="Existing output dir; skip all ids already present in results_shard*.jsonl before re-sharding remaining samples.")
+ p.add_argument("--task_filter", default="", help="Comma-separated question_type filter.")
+ p.add_argument("--rank", type=int, default=0)
+ p.add_argument("--world_size", type=int, default=1)
+ p.add_argument("--tensor_parallel_size", type=int, default=1)
+ p.add_argument("--max_model_len", type=int, default=32768)
+ p.add_argument("--gpu_memory_utilization", type=float, default=0.90)
+ p.add_argument("--max_new_tokens", type=int, default=64)
+ p.add_argument("--batch_size", type=int, default=16)
+ p.add_argument("--score_log_interval", type=int, default=200)
+ p.add_argument("--temperature", type=float, default=0.0)
+ p.add_argument("--top_p", type=float, default=1.0)
+ p.add_argument("--max_samples", type=int, default=0)
+ p.add_argument("--media_mode", choices=["image", "video"], default="video",
+ help="image: use image_list; video: reconstruct/read mp4 and sample frames.")
+ p.add_argument("--video_root", default="",
+ help="Root directory for relative video paths, e.g. VSI-590K root.")
+ p.add_argument("--max_frames", type=int, default=128)
+ p.add_argument("--fps", type=float, default=2.0)
+ p.add_argument(
+ "--exact_nframes",
+ action="store_true",
+ help="Decode exactly max_frames frames; use for fixed ReVSI subsets.",
+ )
+ p.add_argument("--video_total_pixels", type=int, default=16777216)
+ p.add_argument("--video_min_pixels", type=int, default=65536)
+ p.add_argument("--video_max_pixels", type=int, default=None)
+ p.add_argument(
+ "--strict_numeric_prompt",
+ action="store_true",
+ help="Append a strict 'output only a number' suffix to numeric-task "
+ "prompts. Useful when a long SFT has biased the model toward "
+ "single-letter outputs on numeric questions.",
+ )
+ p.add_argument(
+ "--enable_thinking",
+ type=str,
+ default="false",
+ choices=["true", "false"],
+ help="Enable Qwen3 block. Default: false (no_think). "
+ "When false, vLLM is told via chat_template_kwargs to skip the "
+ "... reasoning prefix so the model emits the "
+ "... answer directly.",
+ )
+ return p.parse_args()
+
+
+def main():
+ args = parse_args()
+ rows = load_rows(args.qa_file)
+ items = [normalise_item(r, i, strict_numeric=args.strict_numeric_prompt,
+ video_root=args.video_root)
+ for i, r in enumerate(rows)]
+ if args.task_filter:
+ keep = {x.strip() for x in args.task_filter.split(",") if x.strip()}
+ items = [x for x in items if x["question_type"] in keep]
+ print(f"Task filter: {sorted(keep)} -> {len(items)} samples", flush=True)
+ if args.max_samples and len(items) > args.max_samples:
+ items = items[:args.max_samples]
+
+ # Global resume mode: remove all IDs already written by any previous shard,
+ # then redistribute the remaining samples across the current world_size.
+ if args.resume_dir:
+ done_global = set()
+ for name in os.listdir(args.resume_dir) if os.path.isdir(args.resume_dir) else []:
+ if not (name.startswith("results_shard") and name.endswith(".jsonl")):
+ continue
+ p = os.path.join(args.resume_dir, name)
+ with open(p, "r", encoding="utf-8") as f:
+ for line in f:
+ if not line.strip():
+ continue
+ try:
+ done_global.add(str(json.loads(line).get("id")))
+ except Exception:
+ pass
+ if done_global:
+ before = len(items)
+ items = [x for x in items if str(x["id"]) not in done_global]
+ print(f"Global resume: skip {before - len(items)} done ids from {args.resume_dir}; remaining={len(items)}", flush=True)
+
+ # Contiguous sharding: matches official VSI script.
+ total = len(items)
+ chunk_size = total // args.world_size
+ remainder = total % args.world_size
+ start = args.rank * chunk_size + min(args.rank, remainder)
+ end = start + chunk_size + (1 if args.rank < remainder else 0)
+ shard_items = items[start:end]
+
+ # Resume: skip IDs already present in the shard output.
+ done = set()
+ if os.path.exists(args.output_json_path):
+ with open(args.output_json_path, "r", encoding="utf-8") as f:
+ for line in f:
+ if line.strip():
+ try:
+ done.add(json.loads(line)["id"])
+ except Exception:
+ pass
+ shard_items = [x for x in shard_items if x["id"] not in done]
+
+ if not shard_items:
+ print(f"rank={args.rank}: no remaining samples", flush=True)
+ return
+
+ if args.media_mode == "video":
+ missing = [x for x in shard_items if not x.get("video_path") or not os.path.isfile(x["video_path"])]
+ if missing:
+ ex = missing[0]
+ raise FileNotFoundError(
+ f"video_mode missing {len(missing)} videos; first id={ex['id']} "
+ f"dataset={ex['dataset']} scene={ex['scene_name']} path={ex.get('video_path')!r}"
+ )
+
+ os.makedirs(os.path.dirname(args.output_json_path), exist_ok=True)
+
+ # vLLM local-media permission root for image-url mode.
+ all_img_paths = [p for item in shard_items for p in item["image_list"]]
+ common_prefix = os.path.commonpath(all_img_paths) if all_img_paths else "/"
+ if not os.path.isdir(common_prefix):
+ common_prefix = os.path.dirname(common_prefix)
+ num_images = max((len(x["image_list"]) for x in shard_items), default=0)
+
+ from vllm import LLM, SamplingParams
+
+ print("Initializing vLLM engine...", flush=True)
+ print(f"Model: {args.model_path}", flush=True)
+ print(f"QA: {args.qa_file}", flush=True)
+ print(f"Rank: {args.rank}/{args.world_size}, samples={len(shard_items)} / total={total}", flush=True)
+ print(f"Media mode: {args.media_mode}", flush=True)
+ if args.media_mode == "video":
+ print(f"Video: root={args.video_root} max_frames={args.max_frames} "
+ f"exact_nframes={args.exact_nframes} fps={args.fps} "
+ f"total_pixels={args.video_total_pixels}", flush=True)
+ else:
+ print(f"Images per prompt max: {num_images}, allowed_media={common_prefix}", flush=True)
+ enable_thinking = (str(args.enable_thinking).lower() == "true")
+ print(f"Thinking: {enable_thinking}", flush=True)
+
+ processor = None
+ patch_size = None
+ if args.media_mode == "video":
+ from transformers import AutoProcessor, AutoTokenizer
+ processor = AutoProcessor.from_pretrained(
+ args.model_path, padding_side="left", do_resize=False,
+ trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+ patch_size = processor.image_processor.patch_size
+ print(f"Patch size: {patch_size}", flush=True)
+
+ mm_limit = {"video": 1, "image": 1} if args.media_mode == "video" else {"image": num_images}
+ llm_kwargs = dict(
+ model=args.model_path,
+ tensor_parallel_size=args.tensor_parallel_size,
+ max_model_len=args.max_model_len,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ trust_remote_code=True,
+ limit_mm_per_prompt=mm_limit,
+ )
+ if args.media_mode == "image":
+ llm_kwargs["allowed_local_media_path"] = common_prefix
+
+ llm = LLM(**llm_kwargs)
+ sampling = SamplingParams(
+ max_tokens=args.max_new_tokens,
+ temperature=args.temperature,
+ top_p=args.top_p,
+ )
+ # Passed through to the tokenizer's apply_chat_template inside vLLM.
+ # Qwen3 chat templates expose `enable_thinking` to skip the
+ # `... ` reasoning prefix when set to False.
+ chat_template_kwargs = {"enable_thinking": enable_thinking}
+
+ t0 = time.time()
+ local_records: List[Dict[str, Any]] = []
+ with open(args.output_json_path, "a", encoding="utf-8") as f:
+ for batch_start in tqdm(range(0, len(shard_items), args.batch_size),
+ desc=f"rank{args.rank}"):
+ batch = shard_items[batch_start:batch_start + args.batch_size]
+ if args.media_mode == "video":
+ conversations = [build_video_messages(x, args) for x in batch]
+ llm_inputs = [prepare_video_for_vllm(
+ m, processor, patch_size, enable_thinking=enable_thinking)
+ for m in conversations]
+ outputs = llm.generate(llm_inputs, sampling_params=sampling)
+ else:
+ conversations = [build_conversation(x) for x in batch]
+ outputs = llm.chat(
+ conversations,
+ sampling_params=sampling,
+ chat_template_kwargs=chat_template_kwargs,
+ )
+ for item, output in zip(batch, outputs):
+ pred = output.outputs[0].text
+ metric, score = compute_sample_score(
+ item["question_type"], pred, item["ground_truth"])
+ record = {
+ "id": item["id"],
+ "dataset": item["dataset"],
+ "scene_id": item["scene_id"],
+ "scene_name": item["scene_name"],
+ "num_frames": item["num_frames"],
+ "question_type": item["question_type"],
+ "question": item["question"],
+ "prompt": item["prompt"],
+ "pred": pred,
+ "pred_clean": clean_prediction_for_task(item["question_type"], pred),
+ "ground_truth": item["ground_truth"],
+ "options": item.get("options"),
+ "image_list": item["image_list"],
+ "video_path": item.get("video_path"),
+ "media_mode": args.media_mode,
+ "score": score,
+ "metric": metric,
+ }
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
+ local_records.append(record)
+ f.flush()
+
+ done_n = min(batch_start + args.batch_size, len(shard_items))
+ if done_n % args.score_log_interval == 0 or done_n == len(shard_items):
+ elapsed = max(time.time() - t0, 1e-6)
+ print(f"[{done_n}/{len(shard_items)}] {elapsed:.1f}s "
+ f"({done_n / elapsed:.2f} items/s)", flush=True)
+ print_summary(summarise(local_records), prefix=" ")
+
+ print("FINAL SHARD SUMMARY", flush=True)
+ print_summary(summarise(local_records))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/revsi/merge_multinode_shards.py b/code/eval/task/revsi/merge_multinode_shards.py
new file mode 100644
index 0000000000000000000000000000000000000000..c3e4c8cd586ef1d74260f46a28c3fb0813bcc6a9
--- /dev/null
+++ b/code/eval/task/revsi/merge_multinode_shards.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+"""Merge, validate, and officially summarize ReVSI vLLM shards."""
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from eval_revsi_vllm import print_summary, summarise # noqa: E402
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--output-dir", type=Path, required=True)
+ parser.add_argument("--num-shards", type=int, required=True)
+ parser.add_argument("--expected-samples", type=int, default=0)
+ args = parser.parse_args()
+
+ missing = [
+ shard
+ for shard in range(args.num_shards)
+ if not (args.output_dir / f"results_shard{shard}.jsonl").is_file()
+ ]
+ if missing:
+ raise RuntimeError(
+ f"Missing {len(missing)}/{args.num_shards} ReVSI shards: {missing}"
+ )
+
+ records_by_id = {}
+ for shard in range(args.num_shards):
+ path = args.output_dir / f"results_shard{shard}.jsonl"
+ with path.open(encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, 1):
+ if not line.strip():
+ continue
+ record = json.loads(line)
+ sample_id = record.get("id")
+ if sample_id is None:
+ raise RuntimeError(f"{path}:{line_number}: record has no id")
+ records_by_id[str(sample_id)] = record
+
+ records = list(records_by_id.values())
+ records.sort(
+ key=lambda row: (
+ 0,
+ int(row["id"]),
+ )
+ if str(row["id"]).isdigit()
+ else (1, str(row["id"]))
+ )
+ if args.expected_samples > 0 and len(records) != args.expected_samples:
+ raise RuntimeError(
+ f"Expected {args.expected_samples} unique ReVSI samples, "
+ f"got {len(records)}"
+ )
+
+ merged_path = args.output_dir / "merged_results.jsonl"
+ with merged_path.open("w", encoding="utf-8") as handle:
+ for record in records:
+ handle.write(json.dumps(record, ensure_ascii=False) + "\n")
+
+ summary = summarise(records)
+ summary["num_shards"] = args.num_shards
+ summary["frame_budgets"] = sorted(
+ {str(record.get("num_frames") or "") for record in records}
+ )
+ summary_path = args.output_dir / "summary.json"
+ summary_path.write_text(
+ json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+ print_summary(summary)
+ print(f"Merged: {merged_path}")
+ print(f"Summary: {summary_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/revsi/run_eval_vllm.sh b/code/eval/task/revsi/run_eval_vllm.sh
new file mode 100644
index 0000000000000000000000000000000000000000..27e8ff64528e46c721111e61e4d7533422e65c31
--- /dev/null
+++ b/code/eval/task/revsi/run_eval_vllm.sh
@@ -0,0 +1,321 @@
+#!/bin/bash
+# =============================================================================
+# ReVSI evaluation launcher — vLLM, data-parallel only.
+#
+# Usage:
+# bash eval/task/revsi/run_eval_vllm.sh [MODEL_PATH]
+#
+# Env overrides:
+# REVSI_ROOT root containing 16_frame/32_frame/64_frame/all_frame
+# FRAME_BUDGET 16, 32, 64, or all (default: all)
+# QA_FILE defaults to FRAME_BUDGET/test-00000-of-00001.parquet
+# OUTPUT_ROOT default: outputs/revsi
+# OUTPUT_DIR optional existing run dir; set this to resume an interrupted eval
+# TASK_FILTER comma-separated question_type list, optional
+# MAX_SAMPLES optional quick debug cap
+# TP_SIZE default 1
+# MAX_MODEL_LEN default 32768
+# MAX_NEW_TOKENS default 64
+# BATCH_SIZE default 16
+# GPU_MEM_UTIL default 0.90
+# MEDIA_MODE image (default) or video
+# VIDEO_ROOT root for mp4s in video mode, e.g. VSI-590K root
+# MAX_FRAMES/FPS/VIDEO_TOTAL_PIXELS video sampling controls
+# =============================================================================
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+
+MODEL_PATH="${1:-${MODEL_PATH:-}}"
+REVSI_ROOT="${REVSI_ROOT:-}"
+FRAME_BUDGET="${FRAME_BUDGET:-all}"
+FRAME_DIR="${REVSI_ROOT:+${REVSI_ROOT}/${FRAME_BUDGET}_frame}"
+QA_FILE="${QA_FILE:-${FRAME_DIR:+${FRAME_DIR}/test-00000-of-00001.parquet}}"
+OUTPUT_ROOT="${OUTPUT_ROOT:-${PROJECT_DIR}/outputs/revsi}"
+TASK_FILTER="${TASK_FILTER:-}"
+MAX_SAMPLES="${MAX_SAMPLES:-0}"
+STRICT_NUMERIC_PROMPT="${STRICT_NUMERIC_PROMPT:-0}"
+ENABLE_THINKING="${ENABLE_THINKING:-false}"
+MEDIA_MODE="${MEDIA_MODE:-video}"
+VIDEO_ROOT="${VIDEO_ROOT:-${REVSI_ROOT}}"
+MAX_FRAMES="${MAX_FRAMES:-${FRAME_BUDGET/all/128}}"
+EXACT_NFRAMES="${EXACT_NFRAMES:-1}"
+FPS="${FPS:-2}"
+VIDEO_TOTAL_PIXELS="${VIDEO_TOTAL_PIXELS:-16777216}"
+VIDEO_MIN_PIXELS="${VIDEO_MIN_PIXELS:-65536}"
+VIDEO_MAX_PIXELS="${VIDEO_MAX_PIXELS:-}"
+
+TP_SIZE="${TP_SIZE:-1}"
+MAX_MODEL_LEN="${MAX_MODEL_LEN:-32768}"
+MAX_NEW_TOKENS="${MAX_NEW_TOKENS:-64}"
+BATCH_SIZE="${BATCH_SIZE:-16}"
+GPU_MEM_UTIL="${GPU_MEM_UTIL:-0.90}"
+SCORE_LOG_INTERVAL="${SCORE_LOG_INTERVAL:-200}"
+GLOBAL_SHARD_COUNT="${GLOBAL_SHARD_COUNT:-}"
+GLOBAL_SHARD_OFFSET="${GLOBAL_SHARD_OFFSET:-0}"
+MERGE_SHARDS="${MERGE_SHARDS:-1}"
+EXPECTED_SAMPLES="${EXPECTED_SAMPLES:-0}"
+VLLM_BASE_PORT="${VLLM_BASE_PORT:-}"
+LAUNCH_DELAY="${LAUNCH_DELAY:-2}"
+
+case "${FRAME_BUDGET}" in
+ 16|32|64|all) ;;
+ *)
+ echo "ERROR: FRAME_BUDGET must be 16, 32, 64, or all." >&2
+ exit 2
+ ;;
+esac
+[[ -n "${MODEL_PATH}" ]] || {
+ echo "ERROR: pass MODEL_PATH as the first argument or environment variable." >&2
+ exit 2
+}
+[[ -n "${QA_FILE}" ]] || {
+ echo "ERROR: set QA_FILE or REVSI_ROOT." >&2
+ exit 2
+}
+for path in "${MODEL_PATH}/config.json" "${QA_FILE}"; do
+ [[ -f "${path}" ]] || {
+ echo "ERROR: required input is unavailable: ${path}" >&2
+ exit 1
+ }
+done
+if [[ "${QA_FILE}" == *.parquet ]]; then
+ [[ -n "${FRAME_DIR}" ]] || {
+ echo "ERROR: parquet input requires REVSI_ROOT to locate frame videos." >&2
+ exit 2
+ }
+ compgen -G "${FRAME_DIR}/*.mp4" >/dev/null || {
+ echo "ERROR: no ReVSI videos found under ${FRAME_DIR}; extract video.zip first." >&2
+ exit 1
+ }
+fi
+
+# Prefer the CUDA runtime libraries installed alongside PyTorch. In particular,
+# pip/conda CUDA 12.9 builds need their matching nvJitLink ahead of an older
+# system CUDA toolkit that may already be present in LD_LIBRARY_PATH.
+NVJITLINK_LIB="$(
+ python - <<'PY'
+import site
+from pathlib import Path
+
+roots = [*site.getsitepackages(), site.getusersitepackages()]
+for root in roots:
+ candidate = Path(root) / "nvidia" / "nvjitlink" / "lib"
+ if candidate.is_dir():
+ print(candidate)
+ break
+PY
+)"
+if [[ -n "${NVJITLINK_LIB}" ]]; then
+ export LD_LIBRARY_PATH="${NVJITLINK_LIB}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
+fi
+if ! python -c "import torch; print(f'PyTorch preflight: {torch.__version__} CUDA {torch.version.cuda}')"; then
+ echo "ERROR: PyTorch CUDA libraries cannot be loaded in the active environment." >&2
+ exit 1
+fi
+
+IFS="," read -ra GPULIST <<< "${CUDA_VISIBLE_DEVICES:-$(seq -s, 0 $(($(nvidia-smi -L | wc -l)-1)))}"
+NUM_GPUS=${#GPULIST[@]}
+# Each vLLM subprocess must inherit only its worker-specific GPU mask.
+unset CUDA_VISIBLE_DEVICES
+if (( NUM_GPUS % TP_SIZE != 0 )); then
+ echo "ERROR: NUM_GPUS=$NUM_GPUS must be divisible by TP_SIZE=$TP_SIZE"
+ exit 1
+fi
+DP_SIZE=$(( NUM_GPUS / TP_SIZE ))
+if [[ -z "${VLLM_BASE_PORT}" ]]; then
+ VLLM_BASE_PORT="$(
+ python - "$DP_SIZE" <<'PY'
+import socket
+import sys
+
+count = int(sys.argv[1])
+spacing = 16
+for base in range(48000, 64000 - spacing * count, 128):
+ sockets = []
+ try:
+ for index in range(count):
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", base + index * spacing))
+ sockets.append(sock)
+ except OSError:
+ for sock in sockets:
+ sock.close()
+ continue
+ for sock in sockets:
+ sock.close()
+ print(base)
+ break
+else:
+ raise SystemExit("no free ReVSI vLLM port block found")
+PY
+ )"
+fi
+
+MODEL_TAG=$(basename "${MODEL_PATH%/}")
+TIMESTAMP=$(date +%Y%m%d_%H%M%S)
+RUN_TAG="${RUN_TAG:-eval_revsi_${FRAME_BUDGET}frame_vllm-${MODEL_TAG}-${TIMESTAMP}}"
+# If OUTPUT_DIR points to an existing partial run, eval_revsi_vllm.py will skip
+# IDs already present in results_shard*.jsonl and continue the remaining samples.
+OUTPUT_DIR="${OUTPUT_DIR:-${OUTPUT_ROOT}/${RUN_TAG}}"
+mkdir -p "$OUTPUT_DIR"
+export MODEL_PATH QA_FILE VIDEO_ROOT FRAME_BUDGET MAX_FRAMES EXACT_NFRAMES FPS
+export VIDEO_TOTAL_PIXELS VIDEO_MIN_PIXELS VIDEO_MAX_PIXELS
+export MAX_MODEL_LEN MAX_NEW_TOKENS BATCH_SIZE TP_SIZE MAX_SAMPLES
+export EXPECTED_SAMPLES ENABLE_THINKING VLLM_BASE_PORT
+python - "$OUTPUT_DIR/run_config.json" <<'PY'
+import json
+import os
+import sys
+
+keys = (
+ "MODEL_PATH",
+ "QA_FILE",
+ "VIDEO_ROOT",
+ "FRAME_BUDGET",
+ "MAX_FRAMES",
+ "EXACT_NFRAMES",
+ "FPS",
+ "VIDEO_TOTAL_PIXELS",
+ "VIDEO_MIN_PIXELS",
+ "VIDEO_MAX_PIXELS",
+ "MAX_MODEL_LEN",
+ "MAX_NEW_TOKENS",
+ "BATCH_SIZE",
+ "TP_SIZE",
+ "MAX_SAMPLES",
+ "EXPECTED_SAMPLES",
+ "ENABLE_THINKING",
+ "VLLM_BASE_PORT",
+)
+payload = {key.lower(): os.environ.get(key, "") for key in keys}
+with open(sys.argv[1], "w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+PY
+
+cat <}
+Max samples: ${MAX_SAMPLES}
+Strict num: ${STRICT_NUMERIC_PROMPT}
+Thinking: ${ENABLE_THINKING}
+Media mode: ${MEDIA_MODE}
+Video root: ${VIDEO_ROOT:-}
+Base port: ${VLLM_BASE_PORT}
+Frame budget: ${FRAME_BUDGET} (exact_nframes=${EXACT_NFRAMES})
+Video: max_frames=${MAX_FRAMES} fps=${FPS} total_pixels=${VIDEO_TOTAL_PIXELS}
+==============================================
+EOF
+
+PIDS=()
+cleanup() {
+ echo ""; echo "Caught interrupt, killing workers ..."
+ for pid in "${PIDS[@]}"; do kill -TERM "$pid" 2>/dev/null || true; done
+ wait 2>/dev/null || true
+ exit 1
+}
+trap cleanup INT TERM
+
+EFFECTIVE_SHARD_COUNT="${GLOBAL_SHARD_COUNT:-$DP_SIZE}"
+for IDX in $(seq 0 $((DP_SIZE - 1))); do
+ START=$(( IDX * TP_SIZE ))
+ GLOBAL_IDX=$((GLOBAL_SHARD_OFFSET + IDX))
+ SHARD_PORT=$((VLLM_BASE_PORT + IDX * 16))
+ SHARD_GPUS=""
+ for j in $(seq 0 $((TP_SIZE - 1))); do
+ g=${GPULIST[$((START + j))]}
+ SHARD_GPUS="${SHARD_GPUS}${SHARD_GPUS:+,}${g}"
+ done
+
+ OUT_JSONL="${OUTPUT_DIR}/results_shard${GLOBAL_IDX}.jsonl"
+ STRICT_FLAG=""
+ if [ "$STRICT_NUMERIC_PROMPT" = "1" ] || [ "$STRICT_NUMERIC_PROMPT" = "true" ]; then
+ STRICT_FLAG="--strict_numeric_prompt"
+ fi
+ VIDEO_MAX_PIXELS_ARGS=()
+ if [ -n "$VIDEO_MAX_PIXELS" ]; then
+ VIDEO_MAX_PIXELS_ARGS=(--video_max_pixels "$VIDEO_MAX_PIXELS")
+ fi
+ EXACT_NFRAMES_ARGS=()
+ if [ "$EXACT_NFRAMES" = "1" ] || [ "$EXACT_NFRAMES" = "true" ]; then
+ EXACT_NFRAMES_ARGS=(--exact_nframes)
+ fi
+ CUDA_VISIBLE_DEVICES="$SHARD_GPUS" \
+ VLLM_PORT="$SHARD_PORT" \
+ VLLM_HOST_IP=127.0.0.1 \
+ MASTER_PORT="$SHARD_PORT" \
+ MASTER_ADDR=127.0.0.1 \
+ PYTHONUNBUFFERED=1 \
+ python "${SCRIPT_DIR}/eval_revsi_vllm.py" \
+ --output_json_path "$OUT_JSONL" \
+ --model_path "$MODEL_PATH" \
+ --qa_file "$QA_FILE" \
+ --rank "$GLOBAL_IDX" \
+ --world_size "$EFFECTIVE_SHARD_COUNT" \
+ --tensor_parallel_size "$TP_SIZE" \
+ --max_model_len "$MAX_MODEL_LEN" \
+ --gpu_memory_utilization "$GPU_MEM_UTIL" \
+ --max_new_tokens "$MAX_NEW_TOKENS" \
+ --batch_size "$BATCH_SIZE" \
+ --score_log_interval "$SCORE_LOG_INTERVAL" \
+ --task_filter "$TASK_FILTER" \
+ --max_samples "$MAX_SAMPLES" \
+ --media_mode "$MEDIA_MODE" \
+ --video_root "$VIDEO_ROOT" \
+ --max_frames "$MAX_FRAMES" \
+ --fps "$FPS" \
+ --video_total_pixels "$VIDEO_TOTAL_PIXELS" \
+ --video_min_pixels "$VIDEO_MIN_PIXELS" \
+ "${VIDEO_MAX_PIXELS_ARGS[@]}" \
+ "${EXACT_NFRAMES_ARGS[@]}" \
+ --enable_thinking "$ENABLE_THINKING" \
+ $STRICT_FLAG \
+ > "$OUTPUT_DIR/worker_${GLOBAL_IDX}.log" 2>&1 &
+ PIDS+=($!)
+ echo "Launched shard $GLOBAL_IDX/$EFFECTIVE_SHARD_COUNT on GPU $SHARD_GPUS (PID ${PIDS[-1]})"
+ if [ "$IDX" -lt $((DP_SIZE - 1)) ] && [ "$LAUNCH_DELAY" -gt 0 ]; then
+ sleep "$LAUNCH_DELAY"
+ fi
+done
+
+echo "Waiting for ${DP_SIZE} workers ..."
+FAILED=0
+for i in "${!PIDS[@]}"; do
+ RC=0
+ wait "${PIDS[$i]}" || RC=$?
+ if [ $RC -ne 0 ]; then
+ echo "[FAIL] shard $i (PID ${PIDS[$i]}) exit=$RC"
+ FAILED=1
+ else
+ echo "[DONE] shard $i (PID ${PIDS[$i]})"
+ fi
+done
+
+if [ $FAILED -ne 0 ]; then
+ echo "ERROR: some workers failed; not merging incomplete shards." >&2
+ echo "Logs: $OUTPUT_DIR/worker_*.log" >&2
+ exit 1
+fi
+
+if [ "$MERGE_SHARDS" != "1" ]; then
+ echo "Local ReVSI shard range completed; centralized merge deferred."
+ exit 0
+fi
+
+python "${SCRIPT_DIR}/merge_multinode_shards.py" \
+ --output-dir "$OUTPUT_DIR" \
+ --num-shards "$EFFECTIVE_SHARD_COUNT" \
+ --expected-samples "$EXPECTED_SAMPLES"
+echo "Done: $OUTPUT_DIR"
+exit $FAILED
diff --git a/code/eval/task/segmentation/eval_seg_vllm.py b/code/eval/task/segmentation/eval_seg_vllm.py
new file mode 100644
index 0000000000000000000000000000000000000000..cb1ad48b6b2af572187c05ddf572be80692bef10
--- /dev/null
+++ b/code/eval/task/segmentation/eval_seg_vllm.py
@@ -0,0 +1,446 @@
+#!/usr/bin/env python3
+"""
+Segmentation prompt evaluation with vLLM.
+
+This is the inference stage only. It reads OneThinker-eval style records,
+asks the model for SAM2 prompts (`boxes`, `positive_points`, `negative_points`,
+and optional `time`), and writes `predicted_answer_norm` for the SAM2
+post-processing stage.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Optional
+
+
+os.environ.setdefault("VLLM_USE_V1", "1")
+os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
+os.environ.setdefault("FORCE_QWENVL_VIDEO_READER", "decord")
+os.environ.setdefault("DECORD_EOF_RETRY_MAX", "20480")
+
+_PATCH_DIRS = (
+ Path(__file__).resolve().parents[1],
+ Path(__file__).resolve().parents[2] / "Eval-onethink",
+)
+if os.environ.get("FORCE_QWENVL_VIDEO_READER") == "decord":
+ for _patch_dir in _PATCH_DIRS:
+ if not (_patch_dir / "qwenvl_decord_patch.py").is_file():
+ continue
+ sys.path.insert(0, str(_patch_dir))
+ try:
+ import qwenvl_decord_patch # noqa: F401
+ except Exception as exc:
+ print(f"[warn] failed to import qwenvl_decord_patch: {exc}", file=sys.stderr)
+ break
+
+import torch # noqa: E402
+from qwen_vl_utils import process_vision_info # noqa: E402
+from tqdm import tqdm # noqa: E402
+from transformers import AutoProcessor # noqa: E402
+from vllm import LLM, SamplingParams # noqa: E402
+
+
+# Prompts — single source of truth in eval/task/eval_prompt.py
+# (training tails match data/joint/sft_joint_all.jsonl seg_image / seg_video).
+_EVAL_TASK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _EVAL_TASK_DIR not in sys.path:
+ sys.path.insert(0, _EVAL_TASK_DIR)
+
+from canonical_data import ( # noqa: E402
+ load_json_records,
+ repository_relative_output_path,
+)
+from eval_prompt import ( # noqa: E402
+ TRAIN_SEG_IMAGE_TAIL,
+ TRAIN_SEG_VIDEO_TAIL,
+)
+
+
+ANSWER_RE = re.compile(r"\s*(.*?)\s* ", re.DOTALL | re.IGNORECASE)
+
+
+def extract_answer(text: Optional[str]) -> str:
+ if not isinstance(text, str):
+ return ""
+ match = ANSWER_RE.search(text)
+ return match.group(1).strip() if match else text.strip()
+
+
+def load_json_or_jsonl(path: Path) -> List[Dict[str, Any]]:
+ if path.suffix == ".jsonl":
+ return load_json_records(path)
+ with path.open("r", encoding="utf-8") as f:
+ payload = json.load(f)
+ if isinstance(payload, dict) and isinstance(payload.get("results"), list):
+ return payload["results"]
+ if isinstance(payload, list):
+ return payload
+ raise ValueError(f"Unsupported input JSON format: {path}")
+
+
+def resolve_dataset_path(bench_dir: str, dataset: str) -> Path:
+ raw = Path(dataset)
+ if raw.is_file():
+ return raw
+ base = Path(bench_dir)
+ candidates = [base / dataset, base / f"{dataset}.json", base / f"{dataset}.jsonl"]
+ for path in candidates:
+ if path.is_file():
+ return path
+ raise FileNotFoundError(f"Cannot find dataset {dataset!r} under {bench_dir}")
+
+
+def clean_question(problem: str) -> str:
+ return re.sub(r"^\s*<(image|video)>\s*", "", problem or "", flags=re.IGNORECASE).strip()
+
+
+def build_prompt(example: Dict[str, Any], prompt_mode: str) -> List[Dict[str, Any]]:
+ # Match the joint SFT training prompt verbatim: the question is followed
+ # immediately by the training tail.
+ question = clean_question(str(example.get("problem") or example.get("question") or ""))
+ data_type = str(example.get("data_type") or "").strip().lower()
+ tail = TRAIN_SEG_VIDEO_TAIL if data_type == "video" else TRAIN_SEG_IMAGE_TAIL
+ text = f"{question}\n{tail}"
+ return [{"role": "user", "content": [{"type": "text", "text": text}]}]
+
+
+def resolve_media_path(base_prefix: str, raw_path: str) -> tuple[str, str]:
+ """Return `(absolute_path_for_inference, relative_path_for_output)`.
+
+ OneThinker JSON often stores paths like `./Evaluation/Refcoco/...`, while
+ local mirrors may place media directly under `Refcoco/...`.
+ """
+ if os.path.isabs(raw_path):
+ return raw_path, repository_relative_output_path(raw_path, base_prefix)
+
+ rel = raw_path.lstrip("./")
+ candidates = [rel]
+ if rel.startswith("Evaluation/"):
+ candidates.append(rel[len("Evaluation/"):])
+
+ for cand in candidates:
+ full = os.path.join(base_prefix, cand)
+ if os.path.exists(full):
+ return full, f"./{cand}"
+
+ return os.path.join(base_prefix, rel), f"./{rel}"
+
+
+def canonical_output_path(base_prefix: str, raw_path: str) -> str:
+ return resolve_media_path(base_prefix, raw_path)[1]
+
+
+def build_content(example: Dict[str, Any], args: argparse.Namespace) -> List[Dict[str, Any]]:
+ data_type = str(example.get("data_type") or "").strip().lower()
+ media_path, _ = resolve_media_path(args.base_prefix, str(example.get("path") or ""))
+ if data_type == "image":
+ return [{
+ "type": "image",
+ "image": media_path,
+ "max_pixels": args.max_pixels_image,
+ "min_pixels": args.min_pixels_image,
+ }]
+ if data_type == "video":
+ item = {
+ "type": "video",
+ "video": media_path,
+ "max_pixels": args.max_pixels_video,
+ "max_frames": args.max_frames,
+ "fps": args.fps,
+ }
+ if os.getenv("EVAL_VIDEO_ITEM_ONETHINKER", "0") != "1":
+ item["min_pixels"] = args.min_pixels_video
+ item["total_pixels"] = args.total_pixels_video
+ return [item]
+ raise ValueError(f"Unsupported data_type for segmentation: {data_type!r}")
+
+
+def prepare_vllm_input(
+ messages: List[Dict[str, Any]],
+ processor: Any,
+ patch_size: Optional[int],
+ enable_thinking: bool = False,
+) -> Dict[str, Any]:
+ omit_thinking_kw = os.getenv("EVAL_OMIT_ENABLE_THINKING_KW", "0") == "1"
+ force_do_resize_false = os.getenv("EVAL_FORCE_DO_RESIZE_FALSE", "0") == "1"
+
+ if omit_thinking_kw:
+ # OneThinker eval_bench.py omits this kwarg entirely. Keep this
+ # switch for paper-parity runs with OneThinker checkpoints.
+ text = processor.apply_chat_template(
+ messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ )
+ else:
+ text = processor.apply_chat_template(
+ messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=enable_thinking,
+ )
+ kwargs: Dict[str, Any] = {"return_video_kwargs": True, "return_video_metadata": True}
+ if patch_size is None:
+ patch_size = getattr(getattr(processor, "image_processor", None), "patch_size", None)
+ if patch_size is not None:
+ kwargs["image_patch_size"] = patch_size
+ image_inputs, video_inputs, video_kwargs = process_vision_info(messages, **kwargs)
+ video_kwargs = video_kwargs or {}
+ if force_do_resize_false:
+ # Diagnostic only. For Qwen3.5 video segmentation this can trigger
+ # processor shape mismatches when decoded frames are not patch-aligned.
+ video_kwargs["do_resize"] = False
+ mm_data: Dict[str, Any] = {}
+ if image_inputs is not None:
+ mm_data["image"] = image_inputs
+ if video_inputs is not None:
+ mm_data["video"] = video_inputs
+ return {"prompt": text, "multi_modal_data": mm_data, "mm_processor_kwargs": video_kwargs}
+
+
+def parse_json_answer(answer: str) -> Optional[Dict[str, Any]]:
+ if not answer:
+ return None
+ try:
+ obj = json.loads(answer)
+ return obj if isinstance(obj, dict) else None
+ except Exception:
+ pass
+ start = answer.find("{")
+ if start < 0:
+ return None
+ depth = 0
+ for idx in range(start, len(answer)):
+ if answer[idx] == "{":
+ depth += 1
+ elif answer[idx] == "}":
+ depth -= 1
+ if depth == 0:
+ try:
+ obj = json.loads(answer[start:idx + 1])
+ return obj if isinstance(obj, dict) else None
+ except Exception:
+ return None
+ return None
+
+
+def _is_number_list(value: Any, length: int) -> bool:
+ if not isinstance(value, list) or len(value) != length:
+ return False
+ try:
+ for item in value:
+ float(item)
+ return True
+ except Exception:
+ return False
+
+
+def _is_point_list(value: Any) -> bool:
+ if not isinstance(value, list) or len(value) < 1:
+ return False
+ return all(_is_number_list(point, 2) for point in value)
+
+
+def normalize_seg_prompt(obj: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+ """Normalize common model variants to the SAM2 prompt schema.
+
+ The prompt asks for one bounding box but uses the plural key "boxes". Some
+ models naturally return a list-of-boxes (`[[x1, y1, x2, y2]]`), while the
+ downstream OneThinker SAM2 script expects the single box as a flat list.
+ """
+ if not isinstance(obj, dict):
+ return obj
+ out = dict(obj)
+ boxes = out.get("boxes")
+ if (
+ isinstance(boxes, list)
+ and len(boxes) == 1
+ and _is_number_list(boxes[0], 4)
+ ):
+ out["boxes"] = boxes[0]
+ return out
+
+
+def valid_seg_prompt(obj: Optional[Dict[str, Any]], data_type: str) -> bool:
+ if not isinstance(obj, dict):
+ return False
+ boxes = obj.get("boxes")
+ if not _is_number_list(boxes, 4):
+ return False
+ for key in ("positive_points", "negative_points"):
+ if not _is_point_list(obj.get(key)):
+ return False
+ if data_type == "video":
+ try:
+ float(obj.get("time"))
+ except Exception:
+ return False
+ return True
+
+
+def iter_filtered(data: Iterable[Dict[str, Any]], args: argparse.Namespace) -> List[Dict[str, Any]]:
+ rows = []
+ missing_media = 0
+ for rec in data:
+ if str(rec.get("problem_type", "")).strip().lower() != "segmentation":
+ continue
+ data_type = str(rec.get("data_type") or "").strip().lower()
+ if data_type not in {"image", "video"}:
+ continue
+ if args.data_type != "all" and data_type != args.data_type:
+ continue
+ media_path, _ = resolve_media_path(args.base_prefix, str(rec.get("path") or ""))
+ if args.skip_missing_media and not os.path.exists(media_path):
+ missing_media += 1
+ continue
+ rows.append(rec)
+ if missing_media:
+ print(f"[warn] skipped {missing_media} samples with missing media", file=sys.stderr)
+ if args.max_samples is not None:
+ rows = rows[:args.max_samples]
+ if args.chunk > 1:
+ rows = [r for i, r in enumerate(rows) if i % args.chunk == args.index]
+ return rows
+
+
+def evaluate_dataset(dataset: str, args: argparse.Namespace, llm: LLM, processor: Any, sampling_params: SamplingParams) -> Dict[str, Any]:
+ data_path = resolve_dataset_path(args.bench_dir, dataset)
+ rows = iter_filtered(load_json_or_jsonl(data_path), args)
+
+ outputs: List[Dict[str, Any]] = []
+ for start in tqdm(range(0, len(rows), args.batch_size), desc=f"{dataset}"):
+ batch = rows[start:start + args.batch_size]
+ inputs = []
+ for example in batch:
+ messages = build_prompt(example, args.prompt_mode)
+ user_msg = messages[-1]
+ user_msg = dict(user_msg)
+ user_msg["content"] = build_content(example, args) + user_msg["content"]
+ run_messages = messages[:-1] + [user_msg]
+ inputs.append(prepare_vllm_input(run_messages, processor, args.patch_size, args.enable_thinking))
+
+ generated = llm.generate(inputs, sampling_params=sampling_params)
+ texts = [out.outputs[0].text for out in generated]
+
+ for example, text in zip(batch, texts):
+ answer = extract_answer(text)
+ parsed = parse_json_answer(answer)
+ parsed = normalize_seg_prompt(parsed)
+ data_type = str(example.get("data_type") or "").strip().lower()
+ sample = dict(example)
+ sample["path"] = canonical_output_path(args.base_prefix, str(example.get("path") or ""))
+ sample["output"] = text
+ sample["prediction"] = answer
+ sample["predicted_answer_norm"] = (
+ json.dumps(parsed, ensure_ascii=False) if isinstance(parsed, dict) else answer
+ )
+ sample["parsed_prediction"] = parsed
+ sample["parse_ok"] = valid_seg_prompt(parsed, data_type)
+ outputs.append(sample)
+
+ total = len(outputs)
+ parsed = sum(1 for row in outputs if row.get("parse_ok"))
+ by_type: Dict[str, Dict[str, Any]] = {}
+ for data_type in ("image", "video"):
+ part = [r for r in outputs if r.get("data_type") == data_type]
+ if part:
+ ok = sum(1 for r in part if r.get("parse_ok"))
+ by_type[data_type] = {
+ "num_samples": len(part),
+ "parse_rate": round(ok / len(part) * 100.0, 2),
+ }
+ return {
+ "dataset": dataset,
+ "input_file": str(data_path),
+ "results": outputs,
+ "metrics": {
+ "num_samples": total,
+ "parse_ok": parsed,
+ "parse_rate": round(parsed / total * 100.0, 2) if total else 0.0,
+ "by_data_type": by_type,
+ },
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--model_path", required=True)
+ parser.add_argument("--processor_path", default=None)
+ parser.add_argument("--bench_dir", required=True)
+ parser.add_argument("--datasets", required=True, help="Comma-separated dataset names or JSON/JSONL paths.")
+ parser.add_argument("--output_dir", required=True)
+ parser.add_argument("--base_prefix", default="")
+ parser.add_argument("--data_type", choices=["all", "image", "video"], default="all")
+ parser.add_argument("--prompt_mode", choices=["think", "no_think", "bare", "onethink_system", "train_seg"], default="no_think")
+ parser.add_argument("--enable_thinking", action="store_true", default=False)
+ parser.add_argument("--max_samples", type=int, default=None)
+ parser.add_argument("--skip_missing_media", action="store_true")
+ parser.add_argument("--chunk", type=int, default=1)
+ parser.add_argument("--index", type=int, default=0)
+ parser.add_argument("--batch_size", type=int, default=16)
+ parser.add_argument("--temperature", type=float, default=0.0)
+ parser.add_argument("--top_k", type=int, default=-1)
+ parser.add_argument("--max_new_tokens", type=int, default=1024)
+ parser.add_argument("--tensor_parallel_size", type=int, default=1)
+ parser.add_argument("--gpu_memory_utilization", type=float, default=0.85)
+ parser.add_argument("--max_model_len", type=int, default=32768)
+ parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument("--max_pixels_image", type=int, default=1024 * 32 * 32)
+ parser.add_argument("--min_pixels_image", type=int, default=4 * 32 * 32)
+ parser.add_argument("--max_pixels_video", type=int, default=256 * 32 * 32)
+ parser.add_argument("--min_pixels_video", type=int, default=4 * 32 * 32)
+ parser.add_argument("--total_pixels_video", type=int, default=256 * 64 * 32 * 32)
+ parser.add_argument("--max_frames", type=int, default=128)
+ parser.add_argument("--fps", type=int, default=2)
+ parser.add_argument("--patch_size", type=int, default=None)
+ args = parser.parse_args()
+
+ if args.chunk < 1 or not (0 <= args.index < args.chunk):
+ raise ValueError("--chunk must be >=1 and --index must be in [0, chunk)")
+
+ os.makedirs(args.output_dir, exist_ok=True)
+ torch.manual_seed(args.seed)
+
+ processor = AutoProcessor.from_pretrained(args.processor_path or args.model_path)
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tensor_parallel_size,
+ max_model_len=args.max_model_len,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ mm_encoder_tp_mode="data",
+ seed=args.seed,
+ )
+ sampling_params = SamplingParams(
+ temperature=args.temperature,
+ max_tokens=args.max_new_tokens,
+ top_k=args.top_k,
+ stop_token_ids=[],
+ )
+
+ summary: Dict[str, Any] = {}
+ for dataset in [x.strip() for x in args.datasets.split(",") if x.strip()]:
+ payload = evaluate_dataset(dataset, args, llm, processor, sampling_params)
+ dataset_key = Path(dataset).stem if Path(dataset).suffix else dataset
+ suffix = f"_shard{args.index}" if args.chunk > 1 else ""
+ out_path = Path(args.output_dir) / f"results_{dataset_key}{suffix}.json"
+ with out_path.open("w", encoding="utf-8") as f:
+ json.dump({"results": payload["results"], "metrics": payload["metrics"]}, f, ensure_ascii=False, indent=2)
+ summary[dataset_key] = payload["metrics"]
+ print(
+ f"{dataset_key}: n={payload['metrics']['num_samples']} "
+ f"parse={payload['metrics']['parse_rate']:.2f}% -> {out_path}"
+ )
+
+ summary_name = f"summary_shard{args.index}.json" if args.chunk > 1 else "summary.json"
+ with (Path(args.output_dir) / summary_name).open("w", encoding="utf-8") as f:
+ json.dump(summary, f, ensure_ascii=False, indent=2)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/segmentation/post_sam2.py b/code/eval/task/segmentation/post_sam2.py
new file mode 100644
index 0000000000000000000000000000000000000000..d80e89622c8ad03de062eec8cbcbec08f406cd19
--- /dev/null
+++ b/code/eval/task/segmentation/post_sam2.py
@@ -0,0 +1,491 @@
+#!/usr/bin/env python3
+"""
+Thin project-local wrapper around OneThinker's SAM2 segmentation post-process.
+
+The OneThinker script contains the full image/video mask generation and metric
+logic. This wrapper only injects paths from CLI args so evaluation can be run
+from this repository without editing the third-party checkout.
+"""
+
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import json
+import multiprocessing.spawn as mp_spawn
+import os
+import sys
+import tempfile
+from pathlib import Path
+from typing import Any
+
+
+DEFAULT_ONETHINKER_SCRIPT = str(
+ Path(__file__).resolve().parents[3]
+ / "third_party"
+ / "OneThinker"
+ / "Evaluation"
+ / "Eval"
+ / "seg_post_sam2.py"
+)
+
+
+def sam2_config_name(
+ value: str,
+ package_root: str | Path | None = None,
+) -> str:
+ """Convert an installed SAM2 YAML path to Hydra's package-relative name."""
+
+ path = Path(value).expanduser()
+ if not path.is_absolute():
+ return path.as_posix()
+ if package_root is None:
+ try:
+ import sam2
+ except ImportError:
+ return str(path)
+ package_root = Path(sam2.__file__).resolve().parent
+ try:
+ return path.resolve().relative_to(Path(package_root).resolve()).as_posix()
+ except ValueError:
+ return str(path)
+
+
+def load_module(script_path: str):
+ path = Path(script_path)
+ if not path.is_file():
+ raise FileNotFoundError(f"OneThinker SAM2 script not found: {path}")
+ # Use the real filename as the module name so multiprocessing "spawn"
+ # workers can import it from path.parent when unpickling worker_run.
+ module_name = path.stem
+ spec = importlib.util.spec_from_file_location(module_name, path)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"Failed to load module spec from {path}")
+ module = importlib.util.module_from_spec(spec)
+ sys.path.insert(0, str(path.parent))
+ # multiprocessing pickles functions by module name; dynamic modules must be
+ # registered so child processes can resolve worker_run.
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def _results(payload: Any) -> list[dict[str, Any]]:
+ if isinstance(payload, dict) and isinstance(payload.get("results"), list):
+ return payload["results"]
+ if isinstance(payload, list):
+ return payload
+ raise ValueError("SAM2 input must be a list or an object containing `results`.")
+
+
+def _segmentation_output(sample: dict[str, Any]) -> dict[str, Any] | None:
+ output = sample.get("segmentation_output")
+ if isinstance(output, dict):
+ return output
+ task_payload = sample.get("task_payload")
+ if isinstance(task_payload, dict):
+ output = task_payload.get("segmentation_output")
+ if isinstance(output, dict):
+ sample["segmentation_output"] = output
+ return output
+ return None
+
+
+def _compressed_rle(
+ size: Any,
+ counts: list[Any],
+ cocomask: Any,
+) -> dict[str, Any] | None:
+ if (
+ not isinstance(size, (list, tuple))
+ or len(size) != 2
+ or not all(isinstance(value, (int, float)) for value in size)
+ ):
+ return None
+ height, width = (int(size[0]), int(size[1]))
+ if height <= 0 or width <= 0:
+ return None
+ encoded = cocomask.frPyObjects(
+ {"size": [height, width], "counts": counts},
+ height,
+ width,
+ )
+ if isinstance(encoded, list):
+ encoded = cocomask.merge(encoded)
+ encoded_counts = encoded["counts"]
+ if isinstance(encoded_counts, bytes):
+ encoded_counts = encoded_counts.decode("utf-8")
+ return {"size": [height, width], "counts": encoded_counts}
+
+
+def normalize_missing_rle_counts(payload: Any, cocomask: Any) -> int:
+ """Repair mask annotations that encode empty masks or polygons without RLE counts."""
+
+ repaired = 0
+ for sample in _results(payload):
+ segmentation = _segmentation_output(sample)
+ if segmentation is None:
+ continue
+ rle = segmentation.get("segmentation_rle")
+
+ if sample.get("data_type") == "video":
+ if not isinstance(rle, dict):
+ continue
+ for frame, frame_rle in list(rle.items()):
+ if not isinstance(frame_rle, dict):
+ continue
+ counts = frame_rle.get("counts")
+ if counts is not None and not isinstance(counts, list):
+ continue
+ size = frame_rle.get("size")
+ if (
+ counts is None
+ and (
+ not isinstance(size, (list, tuple))
+ or len(size) != 2
+ )
+ ):
+ continue
+ replacement = _compressed_rle(
+ size,
+ counts
+ if isinstance(counts, list)
+ else [int(size[0]) * int(size[1])],
+ cocomask,
+ )
+ if replacement is not None:
+ rle[frame] = replacement
+ repaired += 1
+ continue
+
+ if not isinstance(rle, dict):
+ continue
+
+ size = rle.get("size")
+ counts = rle.get("counts")
+ if isinstance(counts, list):
+ replacement = _compressed_rle(size, counts, cocomask)
+ if replacement is not None:
+ segmentation["segmentation_rle"] = replacement
+ repaired += 1
+ continue
+ if counts is not None:
+ continue
+
+ polygons = segmentation.get("segmentation_polygon")
+ if polygons:
+ if isinstance(polygons, list) and polygons and isinstance(
+ polygons[0], (int, float)
+ ):
+ polygons = [polygons]
+ if (
+ isinstance(size, (list, tuple))
+ and len(size) == 2
+ and polygons
+ ):
+ height, width = int(size[0]), int(size[1])
+ encoded = cocomask.frPyObjects(polygons, height, width)
+ if isinstance(encoded, list):
+ encoded = cocomask.merge(encoded)
+ counts = encoded["counts"]
+ if isinstance(counts, bytes):
+ counts = counts.decode("utf-8")
+ segmentation["segmentation_rle"] = {
+ "size": [height, width],
+ "counts": counts,
+ }
+ repaired += 1
+ continue
+
+ replacement = None
+ if isinstance(size, (list, tuple)) and len(size) == 2:
+ replacement = _compressed_rle(
+ size,
+ [int(size[0]) * int(size[1])],
+ cocomask,
+ )
+ if replacement is not None:
+ segmentation["segmentation_rle"] = replacement
+ repaired += 1
+
+ return repaired
+
+
+def _sam2_output_path(input_json: str | Path) -> Path:
+ path = Path(input_json)
+ return path.with_name(f"{path.stem}_sam2.json")
+
+
+def _aggregate_rewards(payload: dict[str, Any]) -> None:
+ ok_items = [
+ row
+ for row in _results(payload)
+ if row.get("status") == "ok" and isinstance(row.get("reward"), dict)
+ ]
+ video_j: list[float] = []
+ video_f: list[float] = []
+ video_jf: list[float] = []
+ image_iou: list[float] = []
+ total_inter = 0
+ total_union = 0
+
+ for row in ok_items:
+ reward = row["reward"]
+ if all(key in reward for key in ("J", "F", "J&F")):
+ video_j.append(float(reward["J"]))
+ video_f.append(float(reward["F"]))
+ video_jf.append(float(reward["J&F"]))
+ if "IoU" in reward:
+ image_iou.append(float(reward["IoU"]))
+ total_inter += int(reward.get("inter", 0))
+ total_union += int(reward.get("union", 0))
+
+ averages: dict[str, float] = {}
+ if video_j:
+ averages.update(
+ {
+ "video/J": sum(video_j) / len(video_j),
+ "video/F": sum(video_f) / len(video_f),
+ "video/J&F": sum(video_jf) / len(video_jf),
+ }
+ )
+ if image_iou:
+ giou = sum(image_iou) / len(image_iou)
+ averages.update(
+ {
+ "image/IoU": giou,
+ "image/gIoU": giou,
+ "image/cIoU": (
+ float(total_inter) / float(total_union) if total_union else 0.0
+ ),
+ }
+ )
+ payload["avg_rewards"] = averages
+
+
+def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, temporary = tempfile.mkstemp(
+ dir=path.parent,
+ prefix=f".{path.name}.",
+ suffix=".tmp",
+ )
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
+ json.dump(payload, stream, ensure_ascii=False, indent=2)
+ os.replace(temporary, path)
+ except BaseException:
+ try:
+ os.unlink(temporary)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+def _run_upstream(
+ module: Any,
+ *,
+ input_json: Path,
+ viz_ratio: float,
+ normalize: bool,
+) -> Path:
+ original_load = module.json.load
+
+ if normalize:
+ def load_and_normalize(*args: Any, **kwargs: Any) -> Any:
+ payload = original_load(*args, **kwargs)
+ repaired = normalize_missing_rle_counts(payload, module.cocomask)
+ if repaired:
+ print(f"[INFO] normalized {repaired} mask RLE entries without counts")
+ return payload
+
+ module.json.load = load_and_normalize
+
+ try:
+ sys.argv = [
+ str(Path(module.__file__).name),
+ "--input_json",
+ str(input_json),
+ "--viz_ratio",
+ str(viz_ratio),
+ ]
+ module.main()
+ finally:
+ module.json.load = original_load
+ return _sam2_output_path(input_json)
+
+
+def _retry_errors(
+ module: Any,
+ *,
+ output_path: Path,
+ attempts: int,
+ viz_ratio: float,
+) -> tuple[dict[str, Any], int]:
+ with output_path.open(encoding="utf-8") as stream:
+ payload = json.load(stream)
+
+ retried = 0
+ result_fields = (
+ "reward",
+ "status",
+ "viz_key_pred",
+ "viz_key_gt",
+ "viz_pred_video",
+ "viz_gt_video",
+ )
+ module.WORKERS_PER_GPU = 1
+
+ for attempt in range(1, attempts + 1):
+ failed = [
+ row
+ for row in _results(payload)
+ if str(row.get("status", "")).startswith("error:")
+ ]
+ if not failed:
+ break
+ retried += len(failed)
+ retry_payload = {"results": failed}
+ repaired = normalize_missing_rle_counts(retry_payload, module.cocomask)
+ if repaired:
+ print(f"[INFO] normalized {repaired} mask RLE entries without counts")
+ print(
+ f"[INFO] retrying {len(failed)} SAM2 errors "
+ f"(attempt {attempt}/{attempts}, workers_per_gpu=1)"
+ )
+
+ with tempfile.TemporaryDirectory(
+ dir=output_path.parent,
+ prefix=".sam2-retry-",
+ ) as temporary_dir:
+ retry_input = Path(temporary_dir) / "retry.json"
+ with retry_input.open("w", encoding="utf-8") as stream:
+ json.dump(retry_payload, stream, ensure_ascii=False)
+ retry_output = _run_upstream(
+ module,
+ input_json=retry_input,
+ viz_ratio=viz_ratio,
+ normalize=False,
+ )
+ with retry_output.open(encoding="utf-8") as stream:
+ retry_result = json.load(stream)
+
+ retried_by_id = {
+ row.get("problem_id"): row for row in _results(retry_result)
+ }
+ for row in _results(payload):
+ retry_row = retried_by_id.get(row.get("problem_id"))
+ if retry_row is not None:
+ for field in result_fields:
+ row[field] = retry_row.get(field)
+
+ _aggregate_rewards(payload)
+ _write_json_atomic(output_path, payload)
+ return payload, retried
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--input_json", required=True, help="Prediction JSON from eval_seg_vllm.py.")
+ parser.add_argument("--data_root", required=True, help="Root joined with each record's relative `path`.")
+ parser.add_argument("--sam2_ckpt", required=True)
+ parser.add_argument("--sam2_cfg", required=True)
+ parser.add_argument("--onethinker_script", default=DEFAULT_ONETHINKER_SCRIPT)
+ parser.add_argument("--num_gpus", type=int, default=None)
+ parser.add_argument("--workers_per_gpu", type=int, default=None)
+ parser.add_argument("--pre_extract_threads", type=int, default=None)
+ parser.add_argument("--epoch_size", type=int, default=None)
+ parser.add_argument("--viz_ratio", type=float, default=0.0)
+ parser.add_argument(
+ "--retry_attempts",
+ type=int,
+ default=1,
+ help="Retry per-sample SAM2 errors with one worker per GPU.",
+ )
+ parser.add_argument(
+ "--retry_existing_errors",
+ action="store_true",
+ help="Retry errors in an existing sibling *_sam2.json without rerunning all samples.",
+ )
+ args = parser.parse_args()
+
+ # Python's spawn start method calls os.getcwd() for every new worker pool.
+ # If the command was launched from a transient directory, later epochs can
+ # fail with FileNotFoundError. Pin cwd to the project root.
+ project_dir = Path(__file__).resolve().parents[3]
+ os.chdir(project_dir)
+ original_get_preparation_data = mp_spawn.get_preparation_data
+
+ def get_preparation_data(name):
+ try:
+ return original_get_preparation_data(name)
+ except FileNotFoundError:
+ os.chdir(project_dir)
+ return original_get_preparation_data(name)
+
+ mp_spawn.get_preparation_data = get_preparation_data
+
+ config_name = sam2_config_name(args.sam2_cfg)
+ os.environ["SAM2_DATA_ROOT"] = args.data_root
+ os.environ["SAM2_CKPT"] = args.sam2_ckpt
+ os.environ["SAM2_CFG"] = config_name
+ module = load_module(args.onethinker_script)
+ module.DATA_ROOT = args.data_root
+ module.SAM2_CKPT = args.sam2_ckpt
+ module.SAM2_CFG = config_name
+ if args.num_gpus is not None:
+ module.NUM_GPUS = args.num_gpus
+ if args.workers_per_gpu is not None:
+ module.WORKERS_PER_GPU = args.workers_per_gpu
+ if args.pre_extract_threads is not None:
+ module.PRE_EXTRACT_THREADS = args.pre_extract_threads
+ if args.epoch_size is not None:
+ module.EPOCH_SIZE = args.epoch_size
+
+ sys.argv = [
+ str(Path(args.onethinker_script).name),
+ "--input_json",
+ args.input_json,
+ "--viz_ratio",
+ str(args.viz_ratio),
+ ]
+ os.environ.setdefault(
+ "PYTORCH_CUDA_ALLOC_CONF",
+ "expandable_segments:True,max_split_size_mb:64,garbage_collection_threshold:0.8",
+ )
+ module.mp.set_start_method("spawn", force=True)
+
+ input_path = Path(args.input_json).resolve()
+ output_path = _sam2_output_path(input_path)
+ if not args.retry_existing_errors:
+ output_path = _run_upstream(
+ module,
+ input_json=input_path,
+ viz_ratio=args.viz_ratio,
+ normalize=True,
+ )
+ elif not output_path.is_file():
+ raise FileNotFoundError(f"Existing SAM2 result not found: {output_path}")
+
+ payload, retried = _retry_errors(
+ module,
+ output_path=output_path,
+ attempts=max(0, args.retry_attempts),
+ viz_ratio=args.viz_ratio,
+ )
+ failed = [row for row in _results(payload) if row.get("status") != "ok"]
+ print(
+ f"[INFO] final SAM2 status: ok={len(_results(payload)) - len(failed)} "
+ f"/ total={len(_results(payload))}; retried={retried}"
+ )
+ if failed:
+ examples = ", ".join(
+ f"{row.get('problem_id')}={row.get('status')}" for row in failed[:5]
+ )
+ raise RuntimeError(
+ f"SAM2 post-processing left {len(failed)} failed samples: {examples}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/segmentation/run_eval_vllm.sh b/code/eval/task/segmentation/run_eval_vllm.sh
new file mode 100644
index 0000000000000000000000000000000000000000..ba8208c9c970e18d3f16e15441b6bcfc8ae9bf4c
--- /dev/null
+++ b/code/eval/task/segmentation/run_eval_vllm.sh
@@ -0,0 +1,500 @@
+#!/bin/bash
+# =============================================================================
+# Segmentation evaluation — vLLM inference + optional SAM2 post-processing.
+#
+# Usage:
+# bash eval/task/segmentation/run_eval_vllm.sh [MODEL_PATH] [OUTPUT_DIR]
+#
+# Common overrides:
+# BENCH_DIR=/path/to/OneThinker-eval DATASETS=reasonseg-val DATA_ROOT=/path/to/OneThinker-eval \
+# RUN_SAM2=true SAM2_CKPT=/path/to/sam2.1_hiera_large.pt SAM2_CFG=/path/to/sam2.1_hiera_l.yaml \
+# bash eval/task/segmentation/run_eval_vllm.sh /path/to/ckpt
+# =============================================================================
+
+set -eo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+
+# ---------- paths ----------
+MODEL_PATH="${1:-}"
+PROCESSOR_PATH="${PROCESSOR_PATH:-$MODEL_PATH}"
+BENCH_DIR="${BENCH_DIR:-${PROJECT_DIR}/data/eval/segmentation}"
+DATA_ROOT="${DATA_ROOT:-$BENCH_DIR}"
+DATASETS="${DATASETS:-eval_seg_refcoco,eval_seg_refcocop,eval_seg_refcocog,eval_seg_mevis,eval_seg_reasonvos}"
+
+if [[ -z "$MODEL_PATH" || ! -d "$MODEL_PATH" ]]; then
+ echo "ERROR: MODEL_PATH must name an existing model directory." >&2
+ exit 2
+fi
+if [[ ! -d "$BENCH_DIR" || ! -d "$DATA_ROOT" ]]; then
+ echo "ERROR: BENCH_DIR and DATA_ROOT must name existing directories." >&2
+ exit 2
+fi
+
+# ---------- eval settings ----------
+DATA_TYPE="${DATA_TYPE:-all}" # all | image | video
+PROMPT_MODE="${PROMPT_MODE:-train_seg}" # think | no_think | bare | onethink_system | train_seg
+ENABLE_THINKING="${ENABLE_THINKING:-false}"
+MAX_SAMPLES="${MAX_SAMPLES:-}"
+BATCH_SIZE="${BATCH_SIZE:-16}"
+MAX_NEW_TOKENS="${MAX_NEW_TOKENS:-1024}"
+MAX_PIXELS_IMAGE="${MAX_PIXELS_IMAGE:-1048576}"
+MIN_PIXELS_IMAGE="${MIN_PIXELS_IMAGE:-4096}"
+# Accept both the tracking-style VIDEO_* names and older MAX_PIXELS_VIDEO names.
+VIDEO_MAX_PIXELS="${VIDEO_MAX_PIXELS:-${MAX_PIXELS_VIDEO:-262144}}"
+VIDEO_MIN_PIXELS="${VIDEO_MIN_PIXELS:-${MIN_PIXELS_VIDEO:-4096}}"
+VIDEO_TOTAL_PIXELS="${VIDEO_TOTAL_PIXELS:-${TOTAL_PIXELS_VIDEO:-16777216}}"
+MAX_FRAMES="${MAX_FRAMES:-128}"
+FPS="${FPS:-2}"
+PATCH_SIZE="${PATCH_SIZE:-}"
+VIDEO_READER="${VIDEO_READER:-decord}"
+
+case "$VIDEO_READER" in
+ decord|torchcodec|torchvision) ;;
+ *)
+ echo "ERROR: VIDEO_READER must be decord, torchcodec, or torchvision; got '$VIDEO_READER'" >&2
+ exit 1
+ ;;
+esac
+export FORCE_QWENVL_VIDEO_READER="$VIDEO_READER"
+
+require_int() {
+ local name="$1"
+ local value="$2"
+ if ! [[ "$value" =~ ^[0-9]+$ ]]; then
+ echo "ERROR: $name must be a non-empty integer, got '$value'"
+ exit 1
+ fi
+}
+
+require_int MAX_PIXELS_IMAGE "$MAX_PIXELS_IMAGE"
+require_int MIN_PIXELS_IMAGE "$MIN_PIXELS_IMAGE"
+require_int VIDEO_MAX_PIXELS "$VIDEO_MAX_PIXELS"
+require_int VIDEO_MIN_PIXELS "$VIDEO_MIN_PIXELS"
+require_int VIDEO_TOTAL_PIXELS "$VIDEO_TOTAL_PIXELS"
+require_int MAX_FRAMES "$MAX_FRAMES"
+require_int FPS "$FPS"
+require_int BATCH_SIZE "$BATCH_SIZE"
+require_int MAX_NEW_TOKENS "$MAX_NEW_TOKENS"
+
+# ---------- vLLM ----------
+TP_SIZE="${TP_SIZE:-1}"
+GPU_MEM_UTIL="${GPU_MEM_UTIL:-0.85}"
+MAX_MODEL_LEN="${MAX_MODEL_LEN:-32768}"
+SEED="${SEED:-42}"
+VLLM_BASE_PORT="${VLLM_BASE_PORT:-}"
+RESUME_SHARDS="${RESUME_SHARDS:-false}"
+RETRY_FAILED_SHARDS="${RETRY_FAILED_SHARDS:-true}"
+
+# ---------- SAM2 ----------
+RUN_SAM2="${RUN_SAM2:-false}"
+SAM2_CKPT="${SAM2_CKPT:-}"
+SAM2_CFG="${SAM2_CFG:-}"
+ONETHINKER_SEG_POST="${ONETHINKER_SEG_POST:-${PROJECT_DIR}/third_party/OneThinker/Evaluation/Eval/seg_post_sam2.py}"
+SAM2_NUM_GPUS="${SAM2_NUM_GPUS:-}"
+SAM2_WORKERS_PER_GPU="${SAM2_WORKERS_PER_GPU:-}"
+PRE_EXTRACT_THREADS="${PRE_EXTRACT_THREADS:-4}"
+# Each SAM2 epoch spawns `world_size` worker processes that EACH reload the SAM2
+# model and then handle only their slice of the epoch. Total model loads =
+# world_size * num_epochs. A small epoch with many workers means tiny slices and
+# constant model reloading (the real bottleneck). Keep the epoch large so there
+# is effectively one epoch and each worker amortizes its model load over a big
+# contiguous slice.
+SAM2_EPOCH_SIZE="${SAM2_EPOCH_SIZE:-100000}"
+VIZ_RATIO="${VIZ_RATIO:-0.0}"
+
+# ---------- output ----------
+MODEL_TAG=$(basename "${MODEL_PATH%/}")
+TIMESTAMP=$(date +%Y%m%d_%H%M%S)
+OUTPUT_DIR="${2:-${PROJECT_DIR}/outputs/segmentation/eval_seg_vllm-${MODEL_TAG}-${TIMESTAMP}}"
+
+# ---------- hardware ----------
+NVJITLINK_LIB="$(
+ python - <<'PY'
+import site
+from pathlib import Path
+
+roots = [*site.getsitepackages(), site.getusersitepackages()]
+for root in roots:
+ candidate = Path(root) / "nvidia" / "nvjitlink" / "lib"
+ if candidate.is_dir():
+ print(candidate)
+ break
+PY
+)"
+if [[ -n "${NVJITLINK_LIB}" ]]; then
+ export LD_LIBRARY_PATH="${NVJITLINK_LIB}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
+fi
+if ! python -c "import torch; print(f'PyTorch preflight: {torch.__version__} CUDA {torch.version.cuda}')"; then
+ echo "ERROR: PyTorch CUDA libraries cannot be loaded in the active environment." >&2
+ exit 1
+fi
+
+if [ -n "${CUDA_VISIBLE_DEVICES:-}" ]; then
+ IFS="," read -ra GPULIST <<< "$CUDA_VISIBLE_DEVICES"
+else
+ IFS="," read -ra GPULIST <<< "$(seq -s, 0 $(($(nvidia-smi -L | wc -l)-1)))"
+fi
+NUM_GPUS=${#GPULIST[@]}
+if (( NUM_GPUS % TP_SIZE != 0 )); then
+ echo "ERROR: NUM_GPUS ($NUM_GPUS) must be divisible by TP_SIZE ($TP_SIZE)"
+ exit 1
+fi
+DP_SIZE=$(( NUM_GPUS / TP_SIZE ))
+if [[ -z "${VLLM_BASE_PORT}" ]]; then
+ VLLM_BASE_PORT="$(
+ python - "$DP_SIZE" <<'PY'
+import socket
+import sys
+
+count = int(sys.argv[1])
+spacing = 16
+for base in range(45000, 64000 - spacing * count, 128):
+ sockets = []
+ try:
+ for index in range(count):
+ for offset in (0, 1):
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", base + index * spacing + offset))
+ sockets.append(sock)
+ except OSError:
+ for sock in sockets:
+ sock.close()
+ continue
+ for sock in sockets:
+ sock.close()
+ print(base)
+ break
+else:
+ raise SystemExit("no free segmentation vLLM port block found")
+PY
+ )"
+fi
+
+echo "=============================================="
+echo "Segmentation Evaluation (vLLM)"
+echo "=============================================="
+echo "Model: $MODEL_PATH"
+echo "Processor: $PROCESSOR_PATH"
+echo "Bench dir: $BENCH_DIR"
+echo "Data root: $DATA_ROOT"
+echo "Datasets: $DATASETS"
+echo "Data type: $DATA_TYPE"
+echo "Prompt: $PROMPT_MODE (enable_thinking=$ENABLE_THINKING)"
+echo "Video pix: min=$VIDEO_MIN_PIXELS max=$VIDEO_MAX_PIXELS total=$VIDEO_TOTAL_PIXELS frames=$MAX_FRAMES fps=$FPS"
+echo "Video reader:$VIDEO_READER"
+echo "GPUs: ${GPULIST[*]} (${NUM_GPUS} total, TP=${TP_SIZE}, DP=${DP_SIZE})"
+echo "Base port: $VLLM_BASE_PORT"
+echo "Output: $OUTPUT_DIR"
+echo "SAM2: $RUN_SAM2"
+echo "=============================================="
+
+mkdir -p "$OUTPUT_DIR"
+
+PY_ARGS=(
+ --model_path "$MODEL_PATH"
+ --processor_path "$PROCESSOR_PATH"
+ --bench_dir "$BENCH_DIR"
+ --datasets "$DATASETS"
+ --output_dir "$OUTPUT_DIR"
+ --base_prefix "$DATA_ROOT"
+ --data_type "$DATA_TYPE"
+ --prompt_mode "$PROMPT_MODE"
+ --batch_size "$BATCH_SIZE"
+ --max_new_tokens "$MAX_NEW_TOKENS"
+ --tensor_parallel_size "$TP_SIZE"
+ --gpu_memory_utilization "$GPU_MEM_UTIL"
+ --max_model_len "$MAX_MODEL_LEN"
+ --seed "$SEED"
+ --max_pixels_image "$MAX_PIXELS_IMAGE"
+ --min_pixels_image "$MIN_PIXELS_IMAGE"
+ --max_pixels_video "$VIDEO_MAX_PIXELS"
+ --min_pixels_video "$VIDEO_MIN_PIXELS"
+ --total_pixels_video "$VIDEO_TOTAL_PIXELS"
+ --max_frames "$MAX_FRAMES"
+ --fps "$FPS"
+ --skip_missing_media
+)
+
+if [ -n "$MAX_SAMPLES" ]; then
+ PY_ARGS+=(--max_samples "$MAX_SAMPLES")
+fi
+if [ -n "$PATCH_SIZE" ]; then
+ PY_ARGS+=(--patch_size "$PATCH_SIZE")
+fi
+if [ "$ENABLE_THINKING" = "true" ]; then
+ PY_ARGS+=(--enable_thinking)
+fi
+
+PIDS=()
+PID_SHARDS=()
+cleanup() {
+ echo ""
+ echo "Caught interrupt, killing workers ..."
+ for pid in "${PIDS[@]}"; do
+ kill -TERM "$pid" 2>/dev/null || true
+ done
+ wait 2>/dev/null || true
+ exit 1
+}
+trap cleanup INT TERM
+
+dataset_key() {
+ python -c \
+ "from pathlib import Path; import sys; p=sys.argv[1]; print(Path(p).stem if Path(p).suffix else p)" \
+ "$1"
+}
+
+shard_is_complete() {
+ local shard_id="$1"
+ local dataset
+ local key
+ local datasets=()
+ IFS=',' read -ra datasets <<< "$DATASETS"
+ for dataset in "${datasets[@]}"; do
+ key="$(dataset_key "$dataset")"
+ if [[ ! -s "$OUTPUT_DIR/results_${key}_shard${shard_id}.json" ]]; then
+ return 1
+ fi
+ done
+ return 0
+}
+
+if [ "$DP_SIZE" -eq 1 ]; then
+ CUDA_VISIBLE_DEVICES=$(IFS=,; echo "${GPULIST[*]}") \
+ VLLM_PORT="$VLLM_BASE_PORT" \
+ VLLM_HOST_IP=127.0.0.1 \
+ MASTER_PORT="$((VLLM_BASE_PORT + 1))" \
+ MASTER_ADDR=127.0.0.1 \
+ PYTHONUNBUFFERED=1 python "$SCRIPT_DIR/eval_seg_vllm.py" "${PY_ARGS[@]}" \
+ 2>&1 | tee "$OUTPUT_DIR/run.log"
+else
+ echo ""
+ echo ">>> Launching $DP_SIZE vLLM workers (TP=$TP_SIZE each) ..."
+ for IDX in $(seq 0 $((DP_SIZE - 1))); do
+ if [[ "$RESUME_SHARDS" == "true" ]] && shard_is_complete "$IDX"; then
+ echo " Reusing completed shard $IDX"
+ continue
+ fi
+ START=$(( IDX * TP_SIZE ))
+ SHARD_PORT=$((VLLM_BASE_PORT + IDX * 16))
+ SHARD_MASTER_PORT=$((SHARD_PORT + 1))
+ SHARD_GPUS=""
+ for j in $(seq 0 $((TP_SIZE - 1))); do
+ g=${GPULIST[$((START + j))]}
+ SHARD_GPUS="${SHARD_GPUS}${SHARD_GPUS:+,}${g}"
+ done
+
+ CUDA_VISIBLE_DEVICES="$SHARD_GPUS" \
+ VLLM_PORT="$SHARD_PORT" \
+ VLLM_HOST_IP=127.0.0.1 \
+ MASTER_PORT="$SHARD_MASTER_PORT" \
+ MASTER_ADDR=127.0.0.1 \
+ PYTHONUNBUFFERED=1 \
+ python "$SCRIPT_DIR/eval_seg_vllm.py" "${PY_ARGS[@]}" \
+ --chunk "$DP_SIZE" --index "$IDX" \
+ > "$OUTPUT_DIR/worker_shard${IDX}.log" 2>&1 &
+ PIDS+=($!)
+ PID_SHARDS+=("$IDX")
+ echo " Launched shard $IDX on GPU $SHARD_GPUS (PID ${PIDS[-1]})"
+ done
+
+ FAILED_SHARDS=()
+ for i in "${!PIDS[@]}"; do
+ if ! wait "${PIDS[$i]}"; then
+ FAILED_SHARDS+=("${PID_SHARDS[$i]}")
+ fi
+ done
+
+ if (( ${#FAILED_SHARDS[@]} > 0 )) && [[ "$RETRY_FAILED_SHARDS" == "true" ]]; then
+ echo ""
+ echo ">>> Retrying failed shards sequentially with fresh ports ..."
+ RETRY_FAILURES=()
+ for IDX in "${FAILED_SHARDS[@]}"; do
+ START=$(( IDX * TP_SIZE ))
+ SHARD_GPUS=""
+ for j in $(seq 0 $((TP_SIZE - 1))); do
+ g=${GPULIST[$((START + j))]}
+ SHARD_GPUS="${SHARD_GPUS}${SHARD_GPUS:+,}${g}"
+ done
+ RETRY_PORT="$(
+ python - <<'PY'
+import socket
+
+for base in range(52000, 64000, 8):
+ sockets = []
+ try:
+ for offset in (0, 1):
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", base + offset))
+ sockets.append(sock)
+ except OSError:
+ for sock in sockets:
+ sock.close()
+ continue
+ for sock in sockets:
+ sock.close()
+ print(base)
+ break
+else:
+ raise SystemExit("no free vLLM retry ports found")
+PY
+ )"
+ echo " Retrying shard $IDX on GPU $SHARD_GPUS (ports $RETRY_PORT/$((RETRY_PORT + 1)))"
+ if ! CUDA_VISIBLE_DEVICES="$SHARD_GPUS" \
+ VLLM_PORT="$RETRY_PORT" \
+ VLLM_HOST_IP=127.0.0.1 \
+ MASTER_PORT="$((RETRY_PORT + 1))" \
+ MASTER_ADDR=127.0.0.1 \
+ PYTHONUNBUFFERED=1 \
+ python "$SCRIPT_DIR/eval_seg_vllm.py" "${PY_ARGS[@]}" \
+ --chunk "$DP_SIZE" --index "$IDX" \
+ > "$OUTPUT_DIR/worker_shard${IDX}_retry.log" 2>&1; then
+ RETRY_FAILURES+=("$IDX")
+ fi
+ done
+ FAILED_SHARDS=("${RETRY_FAILURES[@]}")
+ fi
+
+ if (( ${#FAILED_SHARDS[@]} > 0 )); then
+ echo "ERROR: Failed shards: ${FAILED_SHARDS[*]}; not merging or running SAM2."
+ exit 1
+ fi
+
+ echo ""
+ echo ">>> Merging shard results ..."
+ IFS=',' read -ra DATASET_LIST <<< "$DATASETS"
+ for DATASET in "${DATASET_LIST[@]}"; do
+ DATASET_KEY=$(python -c "from pathlib import Path; import sys; p=sys.argv[1]; print(Path(p).stem if Path(p).suffix else p)" "$DATASET")
+ python - "$OUTPUT_DIR" "$DATASET_KEY" "$DP_SIZE" <<'PY'
+import json
+import os
+import sys
+
+out_dir, dataset, num_shards = sys.argv[1], sys.argv[2], int(sys.argv[3])
+all_samples = []
+for sid in range(num_shards):
+ path = os.path.join(out_dir, f"results_{dataset}_shard{sid}.json")
+ if os.path.isfile(path):
+ with open(path, encoding="utf-8") as f:
+ payload = json.load(f)
+ all_samples.extend(payload.get("results", []))
+
+n = len(all_samples)
+if n == 0:
+ print(f" No results for {dataset}")
+ sys.exit(0)
+
+parse_ok = sum(1 for row in all_samples if row.get("parse_ok"))
+summary = {
+ "num_samples": n,
+ "parse_ok": parse_ok,
+ "parse_rate": round(parse_ok / n * 100.0, 2),
+ "by_data_type": {},
+}
+for data_type in ("image", "video"):
+ part = [r for r in all_samples if r.get("data_type") == data_type]
+ if part:
+ ok = sum(1 for r in part if r.get("parse_ok"))
+ summary["by_data_type"][data_type] = {
+ "num_samples": len(part),
+ "parse_rate": round(ok / len(part) * 100.0, 2),
+ }
+
+with open(os.path.join(out_dir, f"results_{dataset}.json"), "w", encoding="utf-8") as f:
+ json.dump({"results": all_samples, "metrics": summary}, f, ensure_ascii=False, indent=2)
+
+summary_path = os.path.join(out_dir, "summary.json")
+full_summary = json.load(open(summary_path, encoding="utf-8")) if os.path.isfile(summary_path) else {}
+full_summary[dataset] = summary
+with open(summary_path, "w", encoding="utf-8") as f:
+ json.dump(full_summary, f, ensure_ascii=False, indent=2)
+
+print(f" {dataset}: n={n} parse={summary['parse_rate']:.2f}%")
+PY
+ done
+fi
+
+if [ "$RUN_SAM2" = "true" ]; then
+ if [ -z "$SAM2_CKPT" ] || [ -z "$SAM2_CFG" ]; then
+ echo "ERROR: RUN_SAM2=true requires SAM2_CKPT and SAM2_CFG."
+ exit 1
+ fi
+ echo ""
+ echo ">>> Running SAM2 post-processing ..."
+ # SAM2 needs visible GPUs. The DP>1 inference path sets CUDA_VISIBLE_DEVICES
+ # only inside per-worker subshells, so the parent env may be empty here; an
+ # empty CUDA_VISIBLE_DEVICES makes torch.cuda.is_available() False and SAM2
+ # falls back to slow CPU serial. Re-derive a non-empty device list.
+ export CUDA_VISIBLE_DEVICES="$(IFS=,; echo "${GPULIST[*]}")"
+ # Default SAM2 GPU count to all visible GPUs unless caller overrides.
+ if [ -z "$SAM2_NUM_GPUS" ]; then
+ SAM2_NUM_GPUS="$NUM_GPUS"
+ fi
+ echo " SAM2 GPUs: $CUDA_VISIBLE_DEVICES (num_gpus=$SAM2_NUM_GPUS)"
+ IFS=',' read -ra DATASET_LIST <<< "$DATASETS"
+ for DATASET in "${DATASET_LIST[@]}"; do
+ DATASET_KEY=$(python -c "from pathlib import Path; import sys; p=sys.argv[1]; print(Path(p).stem if Path(p).suffix else p)" "$DATASET")
+ RESULT_JSON="$OUTPUT_DIR/results_${DATASET_KEY}.json"
+ if [ ! -f "$RESULT_JSON" ]; then
+ echo " Skip $DATASET_KEY: missing $RESULT_JSON"
+ continue
+ fi
+ SAM2_ARGS=(
+ --input_json "$RESULT_JSON"
+ --data_root "$DATA_ROOT"
+ --sam2_ckpt "$SAM2_CKPT"
+ --sam2_cfg "$SAM2_CFG"
+ --onethinker_script "$ONETHINKER_SEG_POST"
+ --pre_extract_threads "$PRE_EXTRACT_THREADS"
+ --epoch_size "$SAM2_EPOCH_SIZE"
+ --viz_ratio "$VIZ_RATIO"
+ )
+ if [ -n "$SAM2_NUM_GPUS" ]; then
+ SAM2_ARGS+=(--num_gpus "$SAM2_NUM_GPUS")
+ fi
+ if [ -n "$SAM2_WORKERS_PER_GPU" ]; then
+ SAM2_ARGS+=(--workers_per_gpu "$SAM2_WORKERS_PER_GPU")
+ fi
+ PYTHONUNBUFFERED=1 python "$SCRIPT_DIR/post_sam2.py" "${SAM2_ARGS[@]}" \
+ 2>&1 | tee "$OUTPUT_DIR/sam2_${DATASET_KEY}.log"
+
+ # Merge SAM2 metrics (cIoU / gIoU / J&F) back into summary.json.
+ SAM2_JSON="$OUTPUT_DIR/results_${DATASET_KEY}_sam2.json"
+ if [ -f "$SAM2_JSON" ]; then
+ python - "$OUTPUT_DIR" "$DATASET_KEY" "$SAM2_JSON" <<'PY'
+import json
+import os
+import sys
+
+out_dir, dataset, sam2_json = sys.argv[1], sys.argv[2], sys.argv[3]
+with open(sam2_json, encoding="utf-8") as f:
+ payload = json.load(f)
+
+metrics = payload.get("metrics", {})
+avg_rewards = payload.get("avg_rewards", {})
+
+summary_path = os.path.join(out_dir, "summary.json")
+full_summary = json.load(open(summary_path, encoding="utf-8")) if os.path.isfile(summary_path) else {}
+entry = full_summary.get(dataset, {})
+if isinstance(metrics, dict):
+ for k in ("num_samples", "parse_ok", "parse_rate"):
+ if k in metrics:
+ entry[k] = metrics[k]
+entry["avg_rewards"] = avg_rewards
+full_summary[dataset] = entry
+with open(summary_path, "w", encoding="utf-8") as f:
+ json.dump(full_summary, f, ensure_ascii=False, indent=2)
+
+parts = ", ".join(f"{k}={v:.4f}" for k, v in avg_rewards.items())
+print(f" {dataset}: {parts}")
+PY
+ fi
+ done
+fi
+
+echo ""
+echo "=============================================="
+echo "Done. Summary: $OUTPUT_DIR/summary.json"
+echo "=============================================="
diff --git a/code/eval/task/spatial_grounding/_grounding_utils.py b/code/eval/task/spatial_grounding/_grounding_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..95d8da792a69a13bc5f11eb4a22a7ad2467d875c
--- /dev/null
+++ b/code/eval/task/spatial_grounding/_grounding_utils.py
@@ -0,0 +1,386 @@
+"""Shared helpers for RefCOCO / RefCOCO+ / RefCOCOg evaluation.
+
+Pure-Python (no torch / vLLM / HF dependencies) so it can be imported by both
+the inference script (``eval_refcoco_vllm.py``) and the offline rescorer
+(``rescore_results.py``).
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import sys
+from typing import Any, Dict, List, Optional, Tuple
+
+
+# ---------------------------------------------------------------------------
+# Prompts — single source of truth in eval/task/eval_prompt.py
+# (qwen_native matches data/joint/sft_joint_all.jsonl spatial grounding samples).
+# ---------------------------------------------------------------------------
+_EVAL_TASK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _EVAL_TASK_DIR not in sys.path:
+ sys.path.insert(0, _EVAL_TASK_DIR)
+
+from canonical_data import load_json_records # noqa: E402
+from eval_prompt import QWEN_NATIVE_PROMPT_SG # noqa: E402
+
+
+def _with_terminal_period(expression: str) -> str:
+ expression = (expression or "").strip()
+ if expression and expression[-1] not in ".?!":
+ expression += "."
+ return expression
+
+
+def build_qwen_native_prompt(expression: str) -> str:
+ # The 13k iou<0.95 training set uses the qwen-native JSON bbox format,
+ # wrapped in tags for consistency with the joint SFT mixture:
+ # Locate "expr." in the image. Output its bounding box in JSON
+ # format within ... tags. Example: ...
+ # Keep eval byte-for-byte aligned on the user text after the token.
+ return QWEN_NATIVE_PROMPT_SG.format(_with_terminal_period(expression))
+
+
+# Alternate grounding prompt styles below are NOT used by the eval pipeline
+# (eval.sh always runs qwen_native). They are retained only for the offline
+# analysis / data-selection tools in this folder (compute_difficulty.py,
+# select_train_data.py), so they live here rather than in eval_prompt.py.
+_QWEN_OFFICIAL_PROMPT_SG = (
+ 'Locate every object that matches the description "{}" in the image. '
+ "Report bbox coordinates in JSON format."
+)
+_EVAL_BENCH_SG_PROMPT = (
+ 'Locate "{}" in the image. Output its bounding box in JSON format '
+ 'within ... tags. '
+ 'Example: [{{"bbox_2d": [123, 30, 404, 846]}}] '
+)
+
+
+def build_qwen_official_prompt(expression: str) -> str:
+ return _QWEN_OFFICIAL_PROMPT_SG.format(expression or "")
+
+
+def build_eval_bench_prompt(expression: str) -> str:
+ return _EVAL_BENCH_SG_PROMPT.format(expression or "")
+
+
+# ---------------------------------------------------------------------------
+# Data loading
+# ---------------------------------------------------------------------------
+
+DATASET_CONFIGS = {
+ "refcoco-val": {"anno": "refcoco_val.json"},
+ "refcoco-testA": {"anno": "refcoco_testA.json"},
+ "refcoco-testB": {"anno": "refcoco_testB.json"},
+ "refcoco-train": {"anno": "refcoco_train.json"},
+ "refcoco+-val": {"anno": "refcocop_val.json"},
+ "refcoco+-testA": {"anno": "refcocop_testA.json"},
+ "refcoco+-testB": {"anno": "refcocop_testB.json"},
+ "refcoco+-train": {"anno": "refcocop_train.json"},
+ "refcocop-val": {"anno": "refcocop_val.json"},
+ "refcocop-testA": {"anno": "refcocop_testA.json"},
+ "refcocop-testB": {"anno": "refcocop_testB.json"},
+ "refcocop-train": {"anno": "refcocop_train.json"},
+ "refcocog-val": {"anno": "refcocog_val.json"},
+ "refcocog-test": {"anno": "refcocog_test.json"},
+ "refcocog-train": {"anno": "refcocog_train.json"},
+}
+CANONICAL_DATASET_NAMES = {
+ "refcoco-val": "refcoco_val",
+ "refcoco-testA": "refcoco_test_a",
+ "refcoco-testB": "refcoco_test_b",
+ "refcoco+-val": "refcocop_val",
+ "refcoco+-testA": "refcocop_test_a",
+ "refcoco+-testB": "refcocop_test_b",
+ "refcocog-val": "refcocog_val",
+ "refcocog-test": "refcocog_test",
+}
+for _canonical_name in CANONICAL_DATASET_NAMES.values():
+ DATASET_CONFIGS.setdefault(
+ _canonical_name,
+ {"anno": f"{_canonical_name}.jsonl"},
+ )
+
+
+def _first_existing_path(paths: List[str]) -> Optional[str]:
+ for path in paths:
+ if path and os.path.isfile(path):
+ return path
+ return None
+
+
+def _resolve_annotation_path(bench_dir: str, anno_name: str, dataset_name: str) -> str:
+ here = os.path.dirname(os.path.abspath(__file__))
+ canonical_name = CANONICAL_DATASET_NAMES.get(dataset_name, dataset_name)
+ candidates = [
+ os.path.join(bench_dir, f"{canonical_name}.jsonl"),
+ os.path.join(bench_dir, "annotations", f"{canonical_name}.jsonl"),
+ os.path.join(bench_dir, anno_name),
+ os.path.join(bench_dir, "rec_jsons_processed", anno_name),
+ os.path.join(bench_dir, "annotations", anno_name),
+ os.path.join(bench_dir, "annotations", "rec_jsons_processed", anno_name),
+ os.path.join(bench_dir, f"{dataset_name}.json"),
+ os.path.join(bench_dir, "rec_jsons_processed", f"{dataset_name}.json"),
+ os.path.join(here, "rec_jsons_processed", anno_name),
+ ]
+ anno_path = _first_existing_path(candidates)
+ if anno_path is None:
+ raise FileNotFoundError(
+ f"Could not find annotations for {dataset_name!r}. Tried: "
+ + ", ".join(candidates)
+ )
+ return anno_path
+
+
+def _resolve_image_root(bench_dir: str, anno_path: str, sample_image_ref: str) -> str:
+ if not sample_image_ref or os.path.isabs(sample_image_ref):
+ return ""
+
+ override = os.getenv("SPATIAL_GROUNDING_IMAGE_ROOT", "").strip()
+ if override:
+ return override
+
+ anno_dir = os.path.dirname(os.path.abspath(anno_path))
+ bench_parent = os.path.dirname(os.path.abspath(bench_dir))
+ rel_dir = os.path.dirname(sample_image_ref)
+ candidates = [
+ bench_dir,
+ os.path.join(bench_dir, "images"),
+ os.path.join(bench_dir, "Spatial-Grounding"),
+ os.path.join(bench_parent, "Spatial-Grounding"),
+ anno_dir,
+ os.path.dirname(anno_dir),
+ ]
+ for root in candidates:
+ probe = os.path.join(root, rel_dir) if rel_dir else root
+ if os.path.isdir(probe):
+ return root
+ return bench_dir
+
+
+def _resolve_image_path(image_ref: str, image_root: str) -> str:
+ if not image_ref:
+ return ""
+ if os.path.isabs(image_ref):
+ return image_ref
+ direct = os.path.join(image_root, image_ref)
+ if os.path.exists(direct):
+ return direct
+ basename_path = os.path.join(image_root, os.path.basename(image_ref))
+ if os.path.exists(basename_path):
+ return basename_path
+ return direct
+
+
+def _as_box(value: Any) -> Optional[List[float]]:
+ if not isinstance(value, (list, tuple)) or len(value) != 4:
+ return None
+ try:
+ return [float(x) for x in value]
+ except (TypeError, ValueError):
+ return None
+
+
+def load_annotations(bench_dir: str, dataset_name: str) -> List[Dict[str, Any]]:
+ cfg = DATASET_CONFIGS.get(
+ dataset_name,
+ {"anno": f"{dataset_name}.jsonl"},
+ )
+ anno_path = _resolve_annotation_path(bench_dir, cfg["anno"], dataset_name)
+ if anno_path.endswith(".jsonl"):
+ raw = load_json_records(anno_path)
+ else:
+ with open(anno_path, "r") as f:
+ raw = json.load(f)
+
+ rows = raw if isinstance(raw, list) else list(raw.values())
+ first_image_ref = ""
+ for item in rows:
+ first_image_ref = item.get("image_path") or item.get("image") or item.get("path") or ""
+ if first_image_ref:
+ break
+ image_root = _resolve_image_root(bench_dir, anno_path, first_image_ref)
+
+ annos: List[Dict[str, Any]] = []
+ for idx, item in enumerate(rows):
+ expression = (
+ item.get("expression")
+ or item.get("normal_caption")
+ or item.get("caption")
+ or item.get("query")
+ or item.get("problem")
+ or ""
+ )
+ bbox = (
+ _as_box(item.get("bbox"))
+ or _as_box(item.get("normalized_solution"))
+ or _as_box(item.get("gt_bbox"))
+ or _as_box(item.get("solution"))
+ )
+ image_ref = item.get("image_path") or item.get("image") or item.get("path") or ""
+ if not expression or bbox is None or not image_ref:
+ continue
+
+ annos.append({
+ "problem_id": item.get("problem_id", item.get("id", idx)),
+ "image_path": _resolve_image_path(image_ref, image_root),
+ "expression": re.sub(r"\s+", " ", str(expression)).strip().strip("."),
+ "bbox": bbox,
+ "width": item.get("width"),
+ "height": item.get("height"),
+ })
+ return annos
+
+
+# ---------------------------------------------------------------------------
+# Bounding-box extraction / scoring
+# ---------------------------------------------------------------------------
+
+ANSWER_RE = re.compile(r"\s*(.*?)\s* ", re.DOTALL | re.IGNORECASE)
+NUMBER_RE = r"-?\d+(?:\.\d+)?"
+
+
+def _clean_jsonish(text: str) -> str:
+ text = (text or "").strip()
+ if text.startswith("```"):
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
+ text = re.sub(r"\s*```$", "", text)
+ return text.strip()
+
+
+def _iter_json_values(text: str) -> List[Any]:
+ decoder = json.JSONDecoder()
+ values: List[Any] = []
+ for i, ch in enumerate(text):
+ if ch not in "[{":
+ continue
+ try:
+ value, _ = decoder.raw_decode(text[i:])
+ except json.JSONDecodeError:
+ continue
+ values.append(value)
+ return values
+
+
+def _append_candidate(
+ out: List[Dict[str, Any]],
+ box: Any,
+ label: Optional[str] = None,
+) -> None:
+ parsed = _as_box(box)
+ if parsed is not None:
+ out.append({"bbox": parsed, "label": label})
+
+
+def _extract_from_json_value(value: Any, out: List[Dict[str, Any]],
+ label: Optional[str] = None) -> None:
+ if isinstance(value, dict):
+ local_label = value.get("label") or value.get("name") or value.get("category") or label
+ for key in ("bbox", "bbox_2d", "box", "boxes", "bboxes"):
+ if key not in value:
+ continue
+ box_value = value[key]
+ if _as_box(box_value) is not None:
+ _append_candidate(out, box_value, local_label)
+ elif isinstance(box_value, list):
+ for entry in box_value:
+ if _as_box(entry) is not None:
+ _append_candidate(out, entry, local_label)
+ else:
+ _extract_from_json_value(entry, out, local_label)
+ for key in ("objects", "items", "regions", "detections", "predictions"):
+ if key in value:
+ _extract_from_json_value(value[key], out, local_label)
+ elif isinstance(value, list):
+ if _as_box(value) is not None:
+ _append_candidate(out, value, label)
+ else:
+ for entry in value:
+ _extract_from_json_value(entry, out, label)
+
+
+def extract_bboxes(text: str) -> List[Dict[str, Any]]:
+ """Extract candidate bboxes from Qwen JSON and legacy R1 answer formats."""
+ if not text:
+ return []
+
+ candidates: List[Dict[str, Any]] = []
+ chunks = [m.group(1) for m in ANSWER_RE.finditer(text)] or [text]
+ for chunk in chunks:
+ cleaned = _clean_jsonish(chunk)
+ for value in _iter_json_values(cleaned):
+ _extract_from_json_value(value, candidates)
+
+ pair_pat = re.compile(
+ rf"\(({NUMBER_RE})\s*,\s*({NUMBER_RE})\)\s*,?\s*"
+ rf"\(({NUMBER_RE})\s*,\s*({NUMBER_RE})\)"
+ )
+ for match in pair_pat.finditer(cleaned):
+ _append_candidate(candidates, [float(x) for x in match.groups()])
+
+ bracket_pat = re.compile(
+ rf"\[\s*({NUMBER_RE})\s*,\s*({NUMBER_RE})\s*,\s*"
+ rf"({NUMBER_RE})\s*,\s*({NUMBER_RE})\s*\]"
+ )
+ for match in bracket_pat.finditer(cleaned):
+ _append_candidate(candidates, [float(x) for x in match.groups()])
+
+ if candidates:
+ return _dedupe_candidates(candidates)
+
+ nums = re.findall(NUMBER_RE, ANSWER_RE.sub(r"\1", text))
+ if len(nums) >= 4:
+ _append_candidate(candidates, [float(x) for x in nums[:4]])
+ return _dedupe_candidates(candidates)
+
+
+def _dedupe_candidates(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ seen = set()
+ unique: List[Dict[str, Any]] = []
+ for cand in candidates:
+ bbox = cand.get("bbox")
+ if bbox is None:
+ continue
+ key = tuple(round(float(x), 6) for x in bbox)
+ if key in seen:
+ continue
+ seen.add(key)
+ unique.append(cand)
+ return unique
+
+
+def compute_iou_2d(box1: Any, box2: Any) -> float:
+ b1 = _as_box(box1)
+ b2 = _as_box(box2)
+ if b1 is None or b2 is None:
+ return 0.0
+
+ x1 = max(b1[0], b2[0])
+ y1 = max(b1[1], b2[1])
+ x2 = min(b1[2], b2[2])
+ y2 = min(b1[3], b2[3])
+ inter = max(0.0, x2 - x1) * max(0.0, y2 - y1)
+ area1 = max(0.0, b1[2] - b1[0]) * max(0.0, b1[3] - b1[1])
+ area2 = max(0.0, b2[2] - b2[0]) * max(0.0, b2[3] - b2[1])
+ union = area1 + area2 - inter
+ return inter / union if union > 1e-12 else 0.0
+
+
+def select_pred_bbox(candidates: List[Dict[str, Any]], gt_box: Any,
+ mode: str = "first") -> Tuple[Optional[List[float]], Optional[str]]:
+ if not candidates:
+ return None, None
+ if mode == "best_iou":
+ best = max(candidates, key=lambda c: compute_iou_2d(gt_box, c.get("bbox")))
+ else:
+ best = candidates[0]
+ return best.get("bbox"), best.get("label")
+
+
+def sanitize_video_kwargs(video_kwargs: Any, has_video: bool = False) -> Dict[str, Any]:
+ if not has_video:
+ return {}
+ if not isinstance(video_kwargs, dict):
+ return {}
+ return {k: v for k, v in video_kwargs.items() if v is not None}
diff --git a/code/eval/task/spatial_grounding/eval_refcoco_vllm.py b/code/eval/task/spatial_grounding/eval_refcoco_vllm.py
new file mode 100644
index 0000000000000000000000000000000000000000..1be870021b42f3f5d87aa8b1fc7ea40d5672ccde
--- /dev/null
+++ b/code/eval/task/spatial_grounding/eval_refcoco_vllm.py
@@ -0,0 +1,480 @@
+"""RefCOCO / RefCOCO+ / RefCOCOg evaluation with the vLLM backend.
+
+Defaults use the joint-SFT answer-only RefCOCO prompt plus ``first``-bbox
+scoring.
+
+Usage:
+ python eval/task/spatial_grounding/eval_refcoco_vllm.py \
+ --model_path /path/to/model \
+ --bench_dir /path/to/OneThinker-eval \
+ --datasets refcoco-val,refcoco-testA,refcoco-testB,\\
+ refcoco+-val,refcoco+-testA,refcoco+-testB,\\
+ refcocog-val,refcocog-test \
+ --output_dir outputs/eval_refcoco_vllm
+"""
+
+import argparse
+import json
+import os
+import time
+from typing import Any, Dict, List
+
+import torch
+from tqdm import tqdm
+
+from _grounding_utils import (
+ DATASET_CONFIGS,
+ build_qwen_native_prompt,
+ compute_iou_2d,
+ extract_bboxes,
+ load_annotations,
+ sanitize_video_kwargs,
+ select_pred_bbox,
+)
+
+
+# ---------------------------------------------------------------------------
+# Args
+# ---------------------------------------------------------------------------
+
+def parse_args():
+ p = argparse.ArgumentParser(description="RefCOCO eval with vLLM")
+ p.add_argument("--model_path", required=True)
+ p.add_argument("--processor_path", default=None,
+ help="Defaults to --model_path.")
+ p.add_argument("--bench_dir", required=True)
+ p.add_argument("--datasets", required=True,
+ help="Comma-separated dataset names. Available: "
+ + ",".join(DATASET_CONFIGS.keys()))
+ p.add_argument("--output_dir", required=True)
+
+ p.add_argument("--prompt_style", default="qwen_native",
+ choices=["qwen_official", "qwen_native", "legacy_r1", "eval_bench"],
+ help="qwen_official (default): Qwen team's RefCOCO eval "
+ "prompt — 'Locate every object that matches the "
+ "description \"...\" in the image. Report bbox "
+ "coordinates in JSON format.' "
+ "qwen_native: simpler 2D-grounding cookbook prompt. "
+ "legacy_r1: OurPO / ms-swift prompt — forces the "
+ "model to emit ' (x1,y1),(x2,y2) ' "
+ "with norm1000 coords (matches "
+ "grounding_prompt_wo_think.txt). Pair with "
+ "--coord_system norm1000 (default when chosen).")
+ p.add_argument("--coord_system", default="auto",
+ choices=["auto", "pixel", "norm1000"],
+ help="What coordinate system the model emits in. Affects "
+ "ONLY pre-IoU rescaling; the OneThinker-eval RefCOCO "
+ "GT is in norm1000, so both branches end up comparing "
+ "in norm1000 space. 'auto' (default): norm1000 if "
+ "--prompt_style=legacy_r1, pixel otherwise. "
+ "'pixel': rescale [0,1] normalised → norm1000 by "
+ "(img_w, img_h); keep larger values as-is (original "
+ "behaviour, works because vanilla Qwen3.5-4B emits "
+ "norm1000 too). 'norm1000': never rescale (the model "
+ "is known to emit norm1000 — no [0,1] heuristic).")
+ p.add_argument("--bbox_select", default="first",
+ choices=["first", "best_iou"],
+ help="When the model returns multiple candidate bboxes: "
+ "'first' (standard RefCOCO protocol, default) or "
+ "'best_iou' (ORACLE — peeks at GT, ablation only).")
+ p.add_argument("--enable_thinking", default="false",
+ choices=["true", "false"],
+ help="Whether to render the chat template in thinking mode.")
+
+ p.add_argument("--min_tokens", type=int, default=64,
+ help="min visual tokens (* downsample^2 = min image pixels)")
+ p.add_argument("--total_tokens", type=int, default=1024,
+ help="max visual tokens (* downsample^2 = max image pixels). "
+ "1024 ≈ 1024x1024 pixels, already covers any COCO image "
+ "at native resolution; bumping higher rarely helps.")
+ p.add_argument("--max_new_tokens", type=int, default=1024)
+
+ p.add_argument("--batch_size", type=int, default=64)
+ p.add_argument(
+ "--max_samples",
+ type=int,
+ default=0,
+ help="Maximum samples per dataset before sharding; 0 evaluates all.",
+ )
+ p.add_argument("--seed", type=int, default=42)
+ p.add_argument("--temperature", type=float, default=0.0)
+
+ p.add_argument("--tensor_parallel_size", type=int,
+ default=max(1, torch.cuda.device_count()))
+ p.add_argument("--gpu_memory_utilization", type=float, default=0.85)
+ p.add_argument("--max_model_len", type=int, default=32768)
+
+ # Data-parallel sharding (one vLLM process per GPU group). When --chunk > 1,
+ # this process handles samples whose global index % chunk == index, and
+ # writes `results__shard{index}.json` for the launcher to merge.
+ p.add_argument("--chunk", type=int, default=1)
+ p.add_argument("--index", type=int, default=0)
+
+ args = p.parse_args()
+ if args.max_samples < 0:
+ p.error("--max_samples must be non-negative")
+ args.enable_thinking = args.enable_thinking == "true"
+ if args.processor_path is None:
+ args.processor_path = args.model_path
+ if args.coord_system == "auto":
+ args.coord_system = (
+ "norm1000" if args.prompt_style == "legacy_r1" else "pixel"
+ )
+ return args
+
+
+# ---------------------------------------------------------------------------
+# Prompt / vLLM input helpers
+# ---------------------------------------------------------------------------
+
+def _detect_downsample_rate(model_path: str) -> int:
+ ml = (model_path or "").lower()
+ if "qwen2" in ml and "qwen3" not in ml:
+ return 28
+ return 32 # qwen3 / qwen3.5 / unknown -> 32
+
+
+_prompt_logged = False
+
+def build_messages(anno: Dict[str, Any], args, dr: int):
+ global _prompt_logged
+ expression = anno["expression"]
+ prompt_text = build_qwen_native_prompt(expression)
+
+ if not _prompt_logged:
+ print(f"\n{'='*60}")
+ print(f"[PROMPT SAMPLE] style={args.prompt_style}, expr={expression!r}")
+ print(f"{'='*60}")
+ print(prompt_text)
+ print(f"{'='*60}\n", flush=True)
+ _prompt_logged = True
+
+ messages = [{
+ "role": "user",
+ "content": [
+ {
+ "type": "image",
+ "image": anno["image_path"],
+ "min_pixels": args.min_tokens * dr * dr,
+ "max_pixels": args.total_tokens * dr * dr,
+ },
+ {"type": "text", "text": prompt_text},
+ ],
+ }]
+ return messages, bool(args.enable_thinking)
+
+
+def prepare_vllm_input(messages, processor, chat_thinking, model_family,
+ process_vision_info):
+ text = processor.apply_chat_template(
+ messages, tokenize=False, add_generation_prompt=True,
+ enable_thinking=chat_thinking,
+ )
+ extra = {}
+ if model_family == "qwen3":
+ extra["image_patch_size"] = getattr(
+ processor.image_processor, "patch_size", 16)
+ image_inputs, video_inputs, video_kwargs = process_vision_info(
+ messages, return_video_kwargs=True, return_video_metadata=True, **extra,
+ )
+ video_kwargs = sanitize_video_kwargs(video_kwargs, has_video=bool(video_inputs))
+
+ mm_data: Dict[str, Any] = {}
+ if image_inputs:
+ mm_data["image"] = image_inputs
+ if video_inputs:
+ mm_data["video"] = video_inputs
+ return {
+ "prompt": text,
+ "multi_modal_data": mm_data,
+ "mm_processor_kwargs": video_kwargs,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Per-split eval loop
+# ---------------------------------------------------------------------------
+
+IOU_THRESHOLDS = (0.5, 0.7, 0.9)
+
+
+def evaluate_dataset(llm, sampling_params, processor, process_vision_info,
+ dataset_name: str, args, model_family: str, dr: int):
+ print(f"\n>>> Evaluating: {dataset_name}")
+ annos = load_annotations(args.bench_dir, dataset_name)
+ annos.sort(key=lambda x: (x["image_path"], x["expression"]))
+ if args.max_samples:
+ annos = annos[: args.max_samples]
+ n_total = len(annos)
+ if args.chunk > 1:
+ annos = [a for i, a in enumerate(annos) if i % args.chunk == args.index]
+ print(f" Shard {args.index}/{args.chunk}: {len(annos)}/{n_total} samples")
+ else:
+ print(f" Loaded {n_total} samples")
+
+ # ---- Resume from append-only partial JSONL ----------------------------
+ suffix = (f"_shard{args.index}" if args.chunk > 1 else "")
+ partial_path = os.path.join(
+ args.output_dir, f"results_{dataset_name}{suffix}.partial.jsonl")
+
+ recall = {t: 0 for t in IOU_THRESHOLDS}
+ ious: List[float] = []
+ n_parsed = 0
+ results: List[Dict[str, Any]] = []
+ done_keys: set = set()
+
+ if os.path.isfile(partial_path):
+ with open(partial_path) as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ rec = json.loads(line)
+ except Exception:
+ continue
+ key = (rec.get("image"), rec.get("expression"))
+ if key in done_keys:
+ continue
+ done_keys.add(key)
+ results.append(rec)
+ iou_val = float(rec.get("iou") or 0.0)
+ ious.append(iou_val)
+ for t in recall:
+ if iou_val >= t:
+ recall[t] += 1
+ if rec.get("pred_bbox") is not None:
+ n_parsed += 1
+ if results:
+ print(f" [resume] loaded {len(results)} cached samples "
+ f"from {os.path.basename(partial_path)}")
+
+ annos = [a for a in annos
+ if (os.path.basename(a["image_path"]), a["expression"])
+ not in done_keys]
+ n = len(annos)
+ if n == 0 and not results:
+ return None, []
+
+ t0 = time.time()
+ bsz = max(1, int(args.batch_size))
+ pf = open(partial_path, "a", buffering=1) if n > 0 else None
+
+ for start in tqdm(range(0, n, bsz), desc=dataset_name):
+ batch = annos[start:start + bsz]
+ vllm_inputs = []
+ for anno in batch:
+ messages, chat_thinking = build_messages(anno, args, dr)
+ try:
+ vllm_inputs.append(prepare_vllm_input(
+ messages, processor, chat_thinking, model_family,
+ process_vision_info,
+ ))
+ except Exception as e:
+ print(f" [warn] prepare failed for {anno.get('image_path')}: {e}",
+ flush=True)
+ vllm_inputs.append(None)
+
+ keep_idx = [i for i, x in enumerate(vllm_inputs) if x is not None]
+ valid_inputs = [vllm_inputs[i] for i in keep_idx]
+ texts: List[str] = [""] * len(batch)
+ if valid_inputs:
+ try:
+ outputs = llm.generate(valid_inputs, sampling_params=sampling_params)
+ for j, out in zip(keep_idx, outputs):
+ texts[j] = out.outputs[0].text
+ except Exception as e:
+ print(f" [error] vLLM generate failed at batch {start}: {e}",
+ flush=True)
+
+ for anno, answer in zip(batch, texts):
+ gt_box = anno["bbox"]
+ candidates = extract_bboxes(answer)
+ n_cands = len(candidates)
+ if n_cands:
+ n_parsed += 1
+ w, h = anno.get("width"), anno.get("height")
+ if not (w and h):
+ try:
+ from PIL import Image as _PILImage
+ with _PILImage.open(anno["image_path"]) as _im:
+ w, h = _im.size
+ anno["width"], anno["height"] = w, h
+ except Exception:
+ w, h = None, None
+ if w and h:
+ for c in candidates:
+ mx = max(c["bbox"])
+ if args.coord_system == "norm1000":
+ # OneThinker-eval RefCOCO ships GT in norm1000
+ # already (e.g. [725, 632, 998, 1000] for a corner-
+ # crop), so predicted bbox in norm1000 is in the
+ # SAME space as GT — no rescale. We keep this
+ # branch (instead of falling through to the
+ # default no-op) for clarity and as an explicit
+ # contract: "I know the model emits norm1000;
+ # don't second-guess via [0,1] heuristics."
+ pass
+ else: # pixel mode (current default behaviour)
+ if mx <= 1.0:
+ # Model emitted [0,1]-normalised; rescale.
+ c["bbox"] = [
+ c["bbox"][0] * w, c["bbox"][1] * h,
+ c["bbox"][2] * w, c["bbox"][3] * h,
+ ]
+
+ pred_box, pred_label = select_pred_bbox(
+ candidates, gt_box, mode=args.bbox_select)
+ iou_val = compute_iou_2d(gt_box, pred_box) if pred_box is not None else 0.0
+ ious.append(iou_val)
+ for t in recall:
+ if iou_val >= t:
+ recall[t] += 1
+
+ rec = {
+ "problem_id": anno.get("problem_id"),
+ "image": os.path.basename(anno["image_path"]),
+ "expression": anno["expression"],
+ "gt_bbox": [round(float(x), 2) for x in gt_box],
+ "pred_bbox": ([round(float(x), 2) for x in pred_box]
+ if pred_box else None),
+ "pred_label": pred_label,
+ "n_candidates": n_cands,
+ "iou": round(iou_val, 4),
+ "answer": answer,
+ }
+ results.append(rec)
+ if pf is not None:
+ pf.write(json.dumps(rec, ensure_ascii=False) + "\n")
+
+ if pf is not None:
+ pf.flush()
+ try:
+ os.fsync(pf.fileno())
+ except OSError:
+ pass
+
+ if pf is not None:
+ pf.close()
+
+ elapsed = time.time() - t0
+ nn = len(ious)
+ metrics = {
+ "num_samples": nn,
+ "mIoU": round(sum(ious) / nn * 100, 2) if nn else 0,
+ }
+ for t in IOU_THRESHOLDS:
+ metrics[f"acc@{t}"] = round(recall[t] / nn * 100, 2) if nn else 0
+ metrics["parse_rate"] = round(n_parsed / nn * 100, 2) if nn else 0
+
+ print(f" {dataset_name}: n={nn} "
+ f"mIoU={metrics['mIoU']:.2f}% "
+ f"acc@0.5={metrics['acc@0.5']:.2f}% "
+ f"acc@0.7={metrics['acc@0.7']:.2f}% "
+ f"acc@0.9={metrics['acc@0.9']:.2f}% "
+ f"Parse={metrics['parse_rate']:.2f}% ({elapsed:.1f}s)")
+ return metrics, results
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def main():
+ args = parse_args()
+ os.makedirs(args.output_dir, exist_ok=True)
+
+ os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
+ from transformers import AutoProcessor
+ from vllm import LLM, SamplingParams
+ from qwen_vl_utils import process_vision_info
+
+ torch.manual_seed(args.seed)
+ print(f"Model: {args.model_path}")
+ print(f"Bench: {args.bench_dir}")
+ print(f"Datasets: {args.datasets}")
+ print(f"TP size: {args.tensor_parallel_size}")
+ print(f"Tokens: total={args.total_tokens}, max_new={args.max_new_tokens}")
+ print(f"Output: {args.output_dir}")
+
+ processor = AutoProcessor.from_pretrained(args.processor_path,
+ trust_remote_code=True)
+ llm_kwargs = dict(
+ model=args.model_path,
+ max_model_len=args.max_model_len,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ tensor_parallel_size=args.tensor_parallel_size,
+ seed=args.seed,
+ limit_mm_per_prompt={"image": 1},
+ trust_remote_code=True,
+ )
+ # Newer vLLM exposes "data" mode for the multimodal encoder; older versions
+ # don't have the kwarg, so fall back gracefully.
+ try:
+ llm = LLM(mm_encoder_tp_mode="data", **llm_kwargs)
+ except TypeError:
+ llm = LLM(**llm_kwargs)
+
+ sampling_params = SamplingParams(
+ temperature=args.temperature,
+ max_tokens=args.max_new_tokens,
+ stop_token_ids=[],
+ )
+
+ model_family = "qwen2" if ("qwen2" in args.model_path.lower()
+ and "qwen3" not in args.model_path.lower()) else "qwen3"
+ dr = _detect_downsample_rate(args.model_path)
+
+ summary_path = os.path.join(args.output_dir, "summary.json")
+ is_shard = args.chunk > 1
+ summary: Dict[str, Any] = {}
+ if not is_shard and os.path.isfile(summary_path):
+ try:
+ with open(summary_path) as f:
+ summary = json.load(f)
+ except Exception:
+ summary = {}
+
+ datasets = [d.strip() for d in args.datasets.split(",") if d.strip()]
+ for ds in datasets:
+ if ds not in DATASET_CONFIGS:
+ print(f"[skip] unknown dataset: {ds}")
+ continue
+ metrics, results = evaluate_dataset(
+ llm, sampling_params, processor, process_vision_info,
+ ds, args, model_family, dr,
+ )
+ if metrics is None:
+ continue
+
+ if is_shard:
+ shard_path = os.path.join(
+ args.output_dir, f"results_{ds}_shard{args.index}.json")
+ with open(shard_path, "w") as f:
+ json.dump(results, f, ensure_ascii=False, indent=2)
+ partial_path = os.path.join(
+ args.output_dir, f"results_{ds}_shard{args.index}.partial.jsonl")
+ if os.path.isfile(partial_path):
+ try: os.remove(partial_path)
+ except OSError: pass
+ print(f" -> {shard_path}")
+ else:
+ result_path = os.path.join(args.output_dir, f"results_{ds}.json")
+ with open(result_path, "w") as f:
+ json.dump(results, f, ensure_ascii=False, indent=2)
+ partial_path = os.path.join(
+ args.output_dir, f"results_{ds}.partial.jsonl")
+ if os.path.isfile(partial_path):
+ try: os.remove(partial_path)
+ except OSError: pass
+ summary[ds] = metrics
+ with open(summary_path, "w") as f:
+ json.dump(summary, f, ensure_ascii=False, indent=2)
+ print(f" -> {result_path}")
+
+ if not is_shard:
+ print(f"\nSummary saved to: {summary_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/spatial_temporal_grounding/eval_stvg_vllm.py b/code/eval/task/spatial_temporal_grounding/eval_stvg_vllm.py
new file mode 100644
index 0000000000000000000000000000000000000000..12573ebaedd2810e437e8a8adeec25b47b250026
--- /dev/null
+++ b/code/eval/task/spatial_temporal_grounding/eval_stvg_vllm.py
@@ -0,0 +1,743 @@
+#!/usr/bin/env python3
+"""
+Spatial-Temporal Video Grounding (STVG) evaluation — vLLM data-parallel.
+
+Inputs: OneThinker-eval style JSON/JSONL under `/.json`.
+Each record has:
+ data_type = "video"
+ problem_type = "spatial-temporal grounding"
+ problem = "...question..."
+ path = "./.mp4"
+ solution/answer = '{"time":[t0,t1],"boxes":{"9":[x1,y1,x2,y2],...}} '
+
+`boxes` can also be a list-of-list (one box per integer second starting at
+round(t0)); we normalize both shapes into a dict keyed by int-second.
+
+Metrics (paper-standard STVG, mirrors Table 6 of OneThinker / LLaVA-ST):
+ tIoU — 1D IoU of `time`
+ tIoU@0.5 — fraction of samples with tIoU >= 0.5
+ sIoU — 2D IoU averaged over EVERY GT frame (missing pred frame = 0)
+ sIoU@0.5 — fraction of samples with sIoU >= 0.5
+Extras:
+ mIoU_inter — 2D IoU averaged over pred ∩ gt frames (lenient, OneThinker
+ training-reward semantics; for training-time comparison)
+ accuracy — 0.5*tIoU + 0.5*mIoU_inter (per-sample legacy combined)
+ parse_rate — frac of samples with a valid JSON answer
+
+Coord system: norm1000 (same domain for gt and pred, no rescaling).
+
+Inference mode: SERVER-SIDE video decoding.
+ - The message only carries the local video path; vLLM workers load and
+ decode the video themselves using qwen_vl_utils, controlled by
+ `mm_processor_kwargs` and `media_io_kwargs` configured on the LLM.
+ - `allowed_local_media_path` is set to `--base_prefix` (or `/` if empty)
+ so vLLM is allowed to read the video files from disk.
+
+Usage:
+ python eval_stvg_vllm.py \\
+ --model_path /path/to/ckpt \\
+ --bench_dir /path/to/OneThinker-eval \\
+ --datasets eval_stvg \\
+ --output_dir outputs/temporal_grounding/...
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+import time as _time
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+os.environ.setdefault("VLLM_USE_V1", "1")
+os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
+os.environ.setdefault("FORCE_QWENVL_VIDEO_READER", "decord")
+os.environ.setdefault("DECORD_EOF_RETRY_MAX", "20480")
+
+
+# ---------------------------------------------------------------------------
+# Prompt — single source of truth in eval/task/eval_prompt.py
+# (matches data/joint/sft_joint_all.jsonl STVG samples).
+# ---------------------------------------------------------------------------
+
+_EVAL_TASK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _EVAL_TASK_DIR not in sys.path:
+ sys.path.insert(0, _EVAL_TASK_DIR)
+
+from canonical_data import load_json_records # noqa: E402
+from eval_prompt import ( # noqa: E402
+ GROUNDING_QUESTION_TEMPLATE_NO_THINK as QUESTION_TEMPLATE_NO_THINK,
+ STVG_TAIL,
+ TRAIN_STVG_QUESTION_PREFIX,
+)
+
+
+# ---------------------------------------------------------------------------
+# Parsing helpers
+# ---------------------------------------------------------------------------
+
+_ANSWER_RE = re.compile(r"\s*(.*?)\s* ", re.DOTALL | re.IGNORECASE)
+
+
+def extract_answer(text: Optional[str]) -> str:
+ if not isinstance(text, str):
+ return ""
+ m = _ANSWER_RE.search(text)
+ return m.group(1).strip() if m else text.strip()
+
+
+def _load_json_relaxed(s: str) -> Optional[dict]:
+ """Try json.loads, then first balanced `{...}` substring."""
+ if not isinstance(s, str):
+ return None
+ s = s.strip()
+ if not s:
+ return None
+ try:
+ obj = json.loads(s)
+ return obj if isinstance(obj, dict) else None
+ except Exception:
+ pass
+ start = s.find("{")
+ if start < 0:
+ return None
+ depth = 0
+ for i in range(start, len(s)):
+ c = s[i]
+ if c == "{":
+ depth += 1
+ elif c == "}":
+ depth -= 1
+ if depth == 0:
+ try:
+ obj = json.loads(s[start:i + 1])
+ return obj if isinstance(obj, dict) else None
+ except Exception:
+ return None
+ return None
+
+
+def _is_list_of_numbers(x, n: Optional[int] = None) -> bool:
+ if not isinstance(x, list):
+ return False
+ if n is not None and len(x) != n:
+ return False
+ try:
+ for v in x:
+ float(v)
+ return True
+ except Exception:
+ return False
+
+
+# ---------------------------------------------------------------------------
+# IoU helpers — identical to eval_bench.py
+# ---------------------------------------------------------------------------
+
+def iou_1d(pred, gt) -> float:
+ if not _is_list_of_numbers(pred, 2) or not _is_list_of_numbers(gt, 2):
+ return 0.0
+ p0, p1 = float(pred[0]), float(pred[1])
+ g0, g1 = float(gt[0]), float(gt[1])
+ if p0 > p1: p0, p1 = p1, p0
+ if g0 > g1: g0, g1 = g1, g0
+ inter = max(0.0, min(p1, g1) - max(p0, g0))
+ union = max(p1, g1) - min(p0, g0)
+ return inter / union if union > 1e-12 else 0.0
+
+
+def iou_2d(b1, b2) -> float:
+ if not _is_list_of_numbers(b1, 4) or not _is_list_of_numbers(b2, 4):
+ return 0.0
+ x1 = max(float(b1[0]), float(b2[0])); y1 = max(float(b1[1]), float(b2[1]))
+ x2 = min(float(b1[2]), float(b2[2])); y2 = min(float(b1[3]), float(b2[3]))
+ inter = max(0.0, x2 - x1) * max(0.0, y2 - y1)
+ a1 = max(0.0, b1[2] - b1[0]) * max(0.0, b1[3] - b1[1])
+ a2 = max(0.0, b2[2] - b2[0]) * max(0.0, b2[3] - b2[1])
+ u = a1 + a2 - inter
+ return inter / u if u > 1e-12 else 0.0
+
+
+def mean_iou_over_intersection(pred_boxes: Dict[str, List[float]],
+ gt_boxes: Dict[str, List[float]]) -> float:
+ """Mean IoU over keys present in BOTH pred and gt (OneThinker / training
+ reward semantics; lenient — does not penalize missing pred frames)."""
+ if not isinstance(pred_boxes, dict) or not isinstance(gt_boxes, dict):
+ return 0.0
+ common = set(pred_boxes.keys()) & set(gt_boxes.keys())
+ if not common:
+ return 0.0
+ vals = [iou_2d(pred_boxes[k], gt_boxes[k]) for k in common]
+ return sum(vals) / len(vals) if vals else 0.0
+
+
+def mean_iou_over_gt_frames(pred_boxes: Dict[str, List[float]],
+ gt_boxes: Dict[str, List[float]]) -> float:
+ """Mean IoU over EVERY gt frame; missing pred frames score 0.
+
+ This is the standard "sIoU" reported in STVG papers
+ (Table 6 of OneThinker / LLaVA-ST), and it strictly penalizes
+ pred frame coverage gaps."""
+ if not isinstance(gt_boxes, dict) or not gt_boxes:
+ return 0.0
+ pred_dict = pred_boxes if isinstance(pred_boxes, dict) else {}
+ total, n = 0.0, 0
+ for k, gbox in gt_boxes.items():
+ total += iou_2d(pred_dict.get(k, []), gbox)
+ n += 1
+ return total / n if n > 0 else 0.0
+
+
+# ---------------------------------------------------------------------------
+# Box-dict normalization (supports dict AND list-of-list)
+# ---------------------------------------------------------------------------
+
+def _normalize_boxes(boxes: Any,
+ time_span: Optional[List[float]] = None
+ ) -> Dict[str, List[float]]:
+ """Coerce boxes into {str(int_sec): [x1,y1,x2,y2]}.
+
+ * dict: int/str-int keys preserved (as str(int)); bad entries dropped.
+ * list: aligned with integer seconds starting at round(time_span[0]);
+ requires a valid 2-number time_span.
+ Other inputs → empty dict.
+ """
+ if isinstance(boxes, dict):
+ out: Dict[str, List[float]] = {}
+ for k, v in boxes.items():
+ try:
+ ki = int(float(k))
+ except Exception:
+ continue
+ if _is_list_of_numbers(v, 4):
+ out[str(ki)] = [float(x) for x in v]
+ return out
+ if isinstance(boxes, list) and _is_list_of_numbers(time_span, 2):
+ t0 = int(round(float(time_span[0])))
+ out = {}
+ for i, b in enumerate(boxes):
+ if _is_list_of_numbers(b, 4):
+ out[str(t0 + i)] = [float(x) for x in b]
+ return out
+ return {}
+
+
+# ---------------------------------------------------------------------------
+# Dataset loading
+# ---------------------------------------------------------------------------
+
+def _read_json_or_jsonl(path: str) -> List[Dict[str, Any]]:
+ return load_json_records(path)
+
+
+_CANONICAL_DATASET_ALIASES = {
+ "eval_stvg": "stvg",
+}
+
+
+def load_dataset(bench_dir: str, name: str) -> List[Dict[str, Any]]:
+ base = name[:-5] if name.endswith(".json") else name
+ base = base[:-6] if base.endswith(".jsonl") else base
+ candidates = (base, _CANONICAL_DATASET_ALIASES.get(base, base))
+ for candidate in dict.fromkeys(candidates):
+ for ext in (".json", ".jsonl"):
+ p = os.path.join(bench_dir, candidate + ext)
+ if os.path.isfile(p):
+ return _read_json_or_jsonl(p)
+ raise FileNotFoundError(
+ f"Dataset not found for {name!r} under {bench_dir}"
+ )
+
+
+def _strip_leading_tags(text: str) -> str:
+ if not isinstance(text, str):
+ return ""
+ return re.sub(r"<(?:video|image)>\s*", "", text, count=1).strip()
+
+
+def _extract_raw_query(problem_text: str) -> str:
+ """Extract the raw query from an eval-style STVG problem string.
+
+ Eval format examples:
+ "Who is watching the ball? Please find the corresponding time period ..."
+ "The person in red shirt? Please find ... When and where ..."
+
+ We take everything before the first occurrence of "Please find" or
+ "When and where" as the raw query.
+ """
+ for sep in ("? Please find the corresponding time period",
+ "? Please describe the location",
+ "? When and where does",
+ "When and where does"):
+ idx = problem_text.find(sep)
+ if idx > 0:
+ return problem_text[:idx].strip()
+ return problem_text.split("?")[0].strip() if "?" in problem_text else problem_text.strip()
+
+
+def build_prompt_text(example: Dict[str, Any], enable_thinking: bool = False,
+ prompt_mode: str = "default") -> str:
+ question = _strip_leading_tags(
+ example.get("problem") or example.get("question") or "")
+ raw_query = _extract_raw_query(question)
+ q = TRAIN_STVG_QUESTION_PREFIX.format(query=raw_query)
+ return QUESTION_TEMPLATE_NO_THINK.format(Question=q) + STVG_TAIL
+
+
+def _resolve_video_path(rec: Dict[str, Any], base_prefix: str) -> Optional[str]:
+ """Resolve a video path. Tries (in order):
+ 1. absolute path as-is
+ 2. /
+ 3. /
+ 4. drop common prefix dirs (e.g. anno records `./Evaluation//...`
+ but disk has only ` //...` without the `Evaluation/` layer)
+ 5. last-resort: 1-level recursive search by basename under
+ Returns absolute path or None (caller should treat None as 'skip').
+ """
+ raw = (rec.get("path") or rec.get("video") or rec.get("video_path")
+ or rec.get("file_name"))
+ if not raw:
+ return None
+ if os.path.isabs(raw) and os.path.isfile(raw):
+ return raw
+ rel = raw.lstrip("./").lstrip("/")
+ base = (base_prefix or "").rstrip("/")
+ if not base:
+ return raw if os.path.isfile(raw) else None
+
+ candidates: List[str] = []
+ candidates.append(os.path.join(base, rel))
+ candidates.append(os.path.join(base, os.path.basename(rel)))
+
+ # Drop one or two leading directory components to handle anno layouts
+ # like "./Evaluation//" living at " //".
+ parts = rel.split("/")
+ for k in (1, 2):
+ if len(parts) > k:
+ candidates.append(os.path.join(base, *parts[k:]))
+
+ for cand in candidates:
+ if os.path.isfile(cand):
+ return cand
+
+ # 1-level recursive lookup by basename under (cheap on small dirs).
+ base_name = os.path.basename(rel)
+ if base_name:
+ for root, _dirs, files in os.walk(base):
+ if base_name in files:
+ return os.path.join(root, base_name)
+ # don't go too deep — STVG benches usually have only 1-2 levels.
+ depth = root[len(base):].count(os.sep)
+ if depth >= 3:
+ _dirs.clear()
+ return None
+
+
+def _extract_gt(rec: Dict[str, Any]):
+ blob = rec.get("solution") or rec.get("answer") or ""
+ ans = extract_answer(blob) if isinstance(blob, str) else ""
+ obj = _load_json_relaxed(ans)
+ if obj is None and isinstance(blob, dict):
+ obj = blob
+ if not isinstance(obj, dict):
+ return None, {}
+ t = obj.get("time")
+ if not _is_list_of_numbers(t, 2):
+ t = None
+ return t, _normalize_boxes(obj.get("boxes"), t)
+
+
+def _extract_pred(text: str):
+ ans = extract_answer(text)
+ obj = _load_json_relaxed(ans)
+ if not isinstance(obj, dict):
+ return None, {}
+ t = obj.get("time")
+ if not _is_list_of_numbers(t, 2):
+ t = None
+ return t, _normalize_boxes(obj.get("boxes"), t)
+
+
+# ---------------------------------------------------------------------------
+# vLLM packing — client-side video decoding (same as verl / eval_timelens_vllm)
+# ---------------------------------------------------------------------------
+
+def _build_video_content(video_path: str, args) -> List[Dict[str, Any]]:
+ """Build message content with video sampling parameters (client-side decode).
+
+ `process_vision_info` will read the video from disk using these kwargs
+ (fps, total_pixels, max_frames, min/max pixels per-frame) to produce
+ decoded tensors. **All of these are forwarded so eval matches training.**
+ """
+ item: Dict[str, Any] = {
+ "type": "video",
+ "video": video_path,
+ "total_pixels": args.video_total_pixels,
+ "max_frames": args.max_frames,
+ "fps": args.fps,
+ }
+ if getattr(args, "video_min_pixels", None) is not None:
+ item["min_pixels"] = args.video_min_pixels
+ if getattr(args, "video_max_pixels", None) is not None:
+ item["max_pixels"] = args.video_max_pixels
+ return [item]
+
+
+def _prepare_for_vllm(messages, processor, patch_size: int,
+ enable_thinking: bool = False):
+ """Build vLLM input dict with client-side decoded video tensors.
+
+ Mirrors verl's rollout: apply_chat_template → process_vision_info →
+ pass multi_modal_data + mm_processor_kwargs to vLLM.
+ """
+ from qwen_vl_utils import process_vision_info
+
+ text = processor.apply_chat_template(
+ messages, tokenize=False, add_generation_prompt=True,
+ enable_thinking=enable_thinking,
+ )
+
+ _images, video_inputs, video_kwargs = process_vision_info(
+ messages,
+ image_patch_size=patch_size,
+ return_video_kwargs=True,
+ return_video_metadata=True,
+ )
+
+ video_kwargs = video_kwargs or {}
+ video_kwargs["do_resize"] = False
+
+ llm_input: Dict[str, Any] = {"prompt": text}
+ if video_inputs:
+ llm_input["multi_modal_data"] = {"video": video_inputs}
+ llm_input["mm_processor_kwargs"] = video_kwargs
+
+ return llm_input
+
+
+# ---------------------------------------------------------------------------
+# Per-dataset evaluation
+# ---------------------------------------------------------------------------
+
+def evaluate_one(llm, sampling_params, processor,
+ dataset_name: str, args) -> Dict[str, Any]:
+ print(f"\n>>> Evaluating {dataset_name}", flush=True)
+ records = load_dataset(args.bench_dir, dataset_name)
+
+ # Deterministic order for shard slicing.
+ records.sort(key=lambda r: (r.get("problem_id", 0),
+ str(r.get("path", ""))))
+
+ max_samples = int(getattr(args, "max_samples", 0) or 0)
+ if max_samples > 0:
+ records = records[:max_samples]
+
+ if args.chunk > 1:
+ records = records[args.index::args.chunk]
+ print(f" Shard {args.index}/{args.chunk}: {len(records)} samples",
+ flush=True)
+ else:
+ print(f" Loaded {len(records)} samples", flush=True)
+
+ # Filter to STVG only (defensive — the file *should* be pure STVG).
+ records = [
+ r for r in records
+ if str(r.get("problem_type", "")).strip().lower()
+ == "spatial-temporal grounding"
+ or not r.get("problem_type") # accept untyped records too
+ ]
+ if not records:
+ print(" [warn] no STVG records after filtering; skipping.", flush=True)
+ return {}
+
+ # ------------------------------------------------------------------
+ # Pre-resolve all video paths up front so we fail loudly (and quickly)
+ # if --base_prefix is wrong, instead of silently scoring 0 for hours.
+ # ------------------------------------------------------------------
+ print(f" resolving video paths under base_prefix={args.base_prefix} ...",
+ flush=True)
+ resolved: List[Optional[str]] = []
+ n_missing = 0
+ for rec in records:
+ vp = _resolve_video_path(rec, args.base_prefix)
+ resolved.append(vp)
+ if vp is None:
+ n_missing += 1
+ if n_missing:
+ # Show first 3 examples to help the user diagnose.
+ examples = [r.get("path") for r, vp in zip(records, resolved) if vp is None][:3]
+ print(f" [warn] {n_missing}/{len(records)} videos missing on disk. "
+ f"First few unresolved paths:", flush=True)
+ for ex in examples:
+ print(f" - {ex!r}", flush=True)
+ if n_missing == len(records):
+ raise SystemExit(
+ "[fatal] 0/{n} videos resolved. Set --base_prefix correctly. "
+ "Got base_prefix={base!r}, anno records reference paths like "
+ "{ex!r}".format(n=len(records), base=args.base_prefix, ex=examples[0])
+ )
+ else:
+ print(f" all {len(records)} videos resolved OK.", flush=True)
+
+ # Per-sample buffers. We track all four standard STVG metrics:
+ # tIoU : 1D temporal IoU (paper "tIoU")
+ # sIoU : mean spatial IoU averaged over EVERY GT frame (paper "sIoU")
+ # mIoU_inter : mean spatial IoU averaged over pred∩gt frames
+ # (lenient, OneThinker training-reward semantics; kept for
+ # reference when comparing against training-time numbers)
+ # accuracy : 0.5*tIoU + 0.5*mIoU_inter (legacy combined score)
+ tious, sious, mious_inter, accs = [], [], [], []
+ n_parsed = 0
+ per_sample = []
+
+ bsz = args.batch_size
+ t0 = _time.time()
+ for start in range(0, len(records), bsz):
+ batch = records[start:start + bsz]
+ batch_paths = resolved[start:start + bsz]
+ inputs_for_vllm = []
+ keep_idx: List[int] = []
+ for j, (rec, vp) in enumerate(zip(batch, batch_paths)):
+ if not vp:
+ continue
+ content = _build_video_content(vp, args)
+ content.append({"type": "text", "text": build_prompt_text(
+ rec, enable_thinking=args.enable_thinking,
+ prompt_mode=args.prompt_mode)})
+ messages = [{"role": "user", "content": content}]
+ try:
+ packed = _prepare_for_vllm(
+ messages, processor, args.patch_size,
+ enable_thinking=args.enable_thinking)
+ except Exception as e:
+ print(f" [warn] prepare failed (pid={rec.get('problem_id')}): {e}",
+ flush=True)
+ continue
+ inputs_for_vllm.append(packed)
+ keep_idx.append(j)
+
+ texts = [""] * len(batch)
+ if inputs_for_vllm:
+ try:
+ outs = llm.generate(inputs_for_vllm,
+ sampling_params=sampling_params)
+ for j, out in zip(keep_idx, outs):
+ texts[j] = out.outputs[0].text
+ except Exception as e:
+ print(f" [error] vLLM generate failed @batch {start}: {e}",
+ flush=True)
+
+ for rec, answer in zip(batch, texts):
+ gt_t, gt_b = _extract_gt(rec)
+ pr_t, pr_b = _extract_pred(answer)
+ parsed = (pr_t is not None) or bool(pr_b)
+ if parsed:
+ n_parsed += 1
+ tiou = iou_1d(pr_t, gt_t) if (pr_t and gt_t) else 0.0
+ siou = mean_iou_over_gt_frames(pr_b, gt_b) if gt_b else 0.0
+ miou_inter = mean_iou_over_intersection(pr_b, gt_b) if gt_b else 0.0
+ acc = 0.5 * tiou + 0.5 * miou_inter
+ tious.append(tiou)
+ sious.append(siou)
+ mious_inter.append(miou_inter)
+ accs.append(acc)
+
+ per_sample.append({
+ "problem_id": rec.get("problem_id"),
+ "path": rec.get("path"),
+ "gt_time": gt_t,
+ "gt_boxes_n": len(gt_b),
+ "pred_time": pr_t,
+ "pred_boxes_n": len(pr_b),
+ "tiou": round(tiou, 4),
+ "siou": round(siou, 4),
+ "miou_inter": round(miou_inter, 4),
+ "accuracy": round(acc, 4),
+ "answer": answer,
+ })
+
+ if (start // bsz) % 10 == 0:
+ done = start + len(batch)
+ el = _time.time() - t0
+ print(f" [{done}/{len(records)}] {el:.1f}s "
+ f"tIoU={sum(tious)/max(len(tious),1):.4f} "
+ f"sIoU={sum(sious)/max(len(sious),1):.4f} "
+ f"parse={n_parsed}/{len(accs)}", flush=True)
+
+ n = len(accs)
+ metrics = {
+ "num_samples": n,
+ # ---- paper-standard STVG metrics ----
+ "tIoU": round(sum(tious) / max(n, 1) * 100, 2),
+ "tIoU@0.5": round(sum(1 for v in tious if v >= 0.5) / max(n, 1) * 100, 2),
+ "sIoU": round(sum(sious) / max(n, 1) * 100, 2),
+ "sIoU@0.5": round(sum(1 for v in sious if v >= 0.5) / max(n, 1) * 100, 2),
+ # ---- extras for OneThinker-style comparison ----
+ "mIoU_inter": round(sum(mious_inter) / max(n, 1) * 100, 2),
+ "mean_accuracy": round(sum(accs) / max(n, 1) * 100, 2),
+ "parse_rate": round(n_parsed / max(n, 1) * 100, 2),
+ }
+ # extra recall thresholds (R@0.3 / 0.7) for both tIoU and sIoU
+ for t in (0.3, 0.7):
+ metrics[f"tIoU@{t}"] = round(
+ sum(1 for v in tious if v >= t) / max(n, 1) * 100, 2)
+ metrics[f"sIoU@{t}"] = round(
+ sum(1 for v in sious if v >= t) / max(n, 1) * 100, 2)
+
+ # Persist per-sample results.
+ os.makedirs(args.output_dir, exist_ok=True)
+ suffix = f"_shard{args.index}" if args.chunk > 1 else ""
+ out_path = os.path.join(
+ args.output_dir, f"results_{dataset_name}{suffix}.json")
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump(per_sample, f, ensure_ascii=False, indent=2)
+
+ print(f" [{dataset_name}] "
+ f"tIoU={metrics['tIoU']:.2f}% tIoU@0.5={metrics['tIoU@0.5']:.2f}% "
+ f"sIoU={metrics['sIoU']:.2f}% sIoU@0.5={metrics['sIoU@0.5']:.2f}% "
+ f"mIoU_inter={metrics['mIoU_inter']:.2f}% "
+ f"parse={metrics['parse_rate']:.2f}% n={n}", flush=True)
+ return metrics
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def parse_args():
+ p = argparse.ArgumentParser(
+ description="STVG evaluation via vLLM (OneThinker prompt).")
+ p.add_argument("--model_path", required=True)
+ p.add_argument("--processor_path", default=None,
+ help="Defaults to --model_path.")
+ p.add_argument("--bench_dir", required=True,
+ help="Dir containing eval_stvg*.json / .jsonl.")
+ p.add_argument("--datasets", default="eval_stvg",
+ help="Comma-separated names (with or without .json).")
+ p.add_argument("--max_samples", type=int, default=0,
+ help="Maximum samples before sharding; 0 evaluates all.")
+ p.add_argument("--output_dir", required=True)
+ p.add_argument("--base_prefix", default="",
+ help="Prefix for relative video paths inside records.")
+ # video sampling (client-side decoding via qwen_vl_utils / process_vision_info)
+ p.add_argument("--video_total_pixels", type=int,
+ default=8 * 1024 * 1024,
+ help="Total pixel budget across all sampled frames "
+ "(default: 8M; matches the STVG training config).")
+ p.add_argument("--video_min_pixels", type=int, default=None,
+ help="Minimum per-frame pixel count. Should match training "
+ "(STVG yaml uses 65536 = 256x256).")
+ p.add_argument("--video_max_pixels", type=int, default=None,
+ help="Maximum per-frame pixel count. Should match training "
+ "(STVG yaml uses 8388608 = 8M).")
+ p.add_argument("--max_pixels_video", type=int, default=None,
+ help="[DEPRECATED] alias for --video_total_pixels.")
+ p.add_argument("--max_frames", type=int, default=128,
+ help="Max sampled frames; matches training default (128).")
+ p.add_argument("--fps", type=int, default=2)
+ # vLLM engine
+ p.add_argument("--tensor_parallel_size", type=int, default=1)
+ p.add_argument("--gpu_memory_utilization", type=float, default=0.85)
+ p.add_argument("--max_model_len", type=int, default=65536)
+ p.add_argument("--max_new_tokens", type=int, default=2048)
+ p.add_argument("--max_num_batched_tokens", type=int, default=65536)
+ p.add_argument("--enforce_eager", action="store_true", default=False)
+ p.add_argument("--seed", type=int, default=42)
+ p.add_argument("--batch_size", type=int, default=4)
+ # thinking mode
+ p.add_argument("--enable_thinking", action="store_true", default=False,
+ help="Enable Qwen3 block. Default: disabled.")
+ p.add_argument("--prompt_mode", type=str, default="default",
+ choices=["default", "bare", "onethink", "train_stvg"],
+ help="'default': full NO_THINK+STVG_TAIL template. "
+ "'bare': raw question only (for SFT ckpts trained on raw prompts). "
+ "'onethink': OneThinker official format — reasoning "
+ "instruction in SYSTEM message, task tail in USER message. "
+ "'train_stvg': rewrite question to training format "
+ "'Given the query \"...\", when and where...' + template.")
+ # sharding
+ p.add_argument("--chunk", type=int, default=1,
+ help="Total shards (for data-parallel launcher).")
+ p.add_argument("--index", type=int, default=0,
+ help="This shard's index in [0, chunk).")
+ args = p.parse_args()
+ if args.max_samples < 0:
+ p.error("--max_samples must be non-negative")
+ return args
+
+
+def main():
+ args = parse_args()
+ if args.processor_path is None:
+ args.processor_path = args.model_path
+
+ if args.max_pixels_video is not None:
+ args.video_total_pixels = args.max_pixels_video
+
+ os.makedirs(args.output_dir, exist_ok=True)
+
+ from vllm import LLM, SamplingParams
+ from transformers import AutoProcessor, AutoTokenizer
+
+ print(f"Model: {args.model_path}", flush=True)
+ print(f"Processor: {args.processor_path}", flush=True)
+ print(f"Bench dir: {args.bench_dir}", flush=True)
+ print(f"Datasets: {args.datasets}", flush=True)
+ print(f"Base prefix: {args.base_prefix}", flush=True)
+ print(f"Video: total_pixels={args.video_total_pixels} "
+ f"max_frames={args.max_frames} fps={args.fps}", flush=True)
+ print(f"Thinking: {args.enable_thinking}", flush=True)
+ print(f"Prompt mode: {args.prompt_mode}", flush=True)
+
+ # Processor + tokenizer — client-side video decoding (like verl rollout)
+ processor = AutoProcessor.from_pretrained(
+ args.processor_path, padding_side="left",
+ do_resize=False, trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(
+ args.processor_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+
+ patch_size = processor.image_processor.patch_size
+ args.patch_size = patch_size
+ print(f"Patch size: {patch_size}", flush=True)
+
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tensor_parallel_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ enforce_eager=args.enforce_eager,
+ enable_chunked_prefill=True,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ seed=args.seed,
+ )
+ sampling_params = SamplingParams(
+ max_tokens=args.max_new_tokens,
+ temperature=0.0,
+ top_p=1.0,
+ stop_token_ids=[],
+ )
+
+ all_metrics: Dict[str, Dict[str, Any]] = {}
+ for ds in [s.strip() for s in args.datasets.split(",") if s.strip()]:
+ metrics = evaluate_one(llm, sampling_params, processor, ds, args)
+ if metrics:
+ all_metrics[ds] = metrics
+
+ # Write summary (per-shard file if sharded).
+ suffix = f"_shard{args.index}" if args.chunk > 1 else ""
+ summary_path = os.path.join(args.output_dir, f"summary{suffix}.json")
+ with open(summary_path, "w", encoding="utf-8") as f:
+ json.dump(all_metrics, f, ensure_ascii=False, indent=2)
+ print(f"\nSummary -> {summary_path}", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/temporal_grounding/eval_timelens_hf.py b/code/eval/task/temporal_grounding/eval_timelens_hf.py
new file mode 100644
index 0000000000000000000000000000000000000000..37d282305806a02811748834f12d1528acf0129c
--- /dev/null
+++ b/code/eval/task/temporal_grounding/eval_timelens_hf.py
@@ -0,0 +1,622 @@
+"""
+Evaluate temporal grounding on TimeLens-Bench with HuggingFace transformers.
+
+Self-contained: no dependency on nncore or the timelens package.
+Supports Qwen3-VL / Qwen3.5-VL model families.
+
+Usage (single GPU):
+ python eval/eval_timelens_hf.py \
+ --model_path /path/to/model \
+ --bench_dir /path/to/TimeLens-Bench \
+ --dataset charades-timelens \
+ --output_dir outputs/eval_run
+
+Multi-GPU (launched by run_eval.sh):
+ CUDA_VISIBLE_DEVICES=0 python eval/eval_timelens_hf.py \
+ --model_path /path/to/model ... --chunk 8 --index 0 &
+ ...
+"""
+
+import argparse
+import copy
+import json
+import logging
+import os
+import random
+import re
+import time
+import warnings
+from pathlib import Path
+
+os.environ.setdefault("FORCE_QWENVL_VIDEO_READER", "decord")
+
+import numpy as np
+import torch
+from qwen_vl_utils import process_vision_info
+from torch.utils.data import DataLoader, Dataset
+from tqdm import tqdm
+from transformers import AutoModelForImageTextToText, AutoProcessor, StoppingCriteria, StoppingCriteriaList
+
+warnings.filterwarnings("ignore", message=".*pad_token_id.*")
+warnings.filterwarnings(
+ "ignore",
+ message="The video decoding and encoding capabilities of torchvision are deprecated.*",
+)
+logging.getLogger("transformers").setLevel(logging.ERROR)
+
+
+class StopOnDecodedText(StoppingCriteria):
+ def __init__(self, tokenizer, start_len: int, stop_text: str):
+ super().__init__()
+ self.tokenizer = tokenizer
+ self.start_len = start_len
+ self.stop_text = stop_text
+
+ def __call__(self, input_ids, scores, **kwargs):
+ generated = input_ids[:, self.start_len:]
+ if generated.numel() == 0:
+ return False
+ stopped = []
+ for seq in generated:
+ text = self.tokenizer.decode(seq, skip_special_tokens=True, clean_up_tokenization_spaces=False)
+ stopped.append(self.stop_text in text)
+ return all(stopped)
+
+
+def truncate_after_first_answer(text: str) -> str:
+ end = text.find(" ")
+ if end < 0:
+ return text
+ return text[: end + len(" ")]
+
+
+# ---------------------------------------------------------------------------
+# Prompts (aligned with TimeLens evaluation)
+# ---------------------------------------------------------------------------
+
+# Temporal grounding prompts — single source of truth in
+# eval/task/eval_prompt.py (PROMPT_WO_THINK matches sft_joint_all.jsonl).
+import sys as _sys
+_EVAL_TASK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _EVAL_TASK_DIR not in _sys.path:
+ _sys.path.insert(0, _EVAL_TASK_DIR)
+
+from canonical_data import canonical_dataset_record, load_json_records # noqa: E402
+from eval_prompt import PROMPT_WO_THINK, TIMELENS_OFFICIAL_PROMPT # noqa: E402
+
+GROUNDER_PROMPT = TIMELENS_OFFICIAL_PROMPT
+
+
+# ---------------------------------------------------------------------------
+# Timestamp extraction (from TimeLens)
+# ---------------------------------------------------------------------------
+
+def extract_time(paragraph):
+ paragraph = paragraph.lower()
+
+ # Isolate the final answer. Prefer the LAST ... block; if the
+ # closing tag is missing (generation truncated, or stripped by a stop string),
+ # fall back to everything after the last opening . This prevents
+ # parsing timestamps out of the CoT reasoning that precedes the answer.
+ answer_blocks = re.findall(r"\s*(.*?)\s* ", paragraph)
+ if answer_blocks:
+ paragraph = answer_blocks[-1]
+ else:
+ open_idx = paragraph.rfind("")
+ if open_idx >= 0:
+ paragraph = paragraph[open_idx + len(""):]
+
+ timestamps = []
+
+ time_regex = re.compile(
+ r"\b(\d{1,2}:\d{2}:\d{2}(?:\.\d+)?|\d{1,2}:\d{2}(?:\.\d+)?)\b"
+ )
+ time_matches = re.findall(time_regex, paragraph)
+ time_matches = time_matches[: len(time_matches) // 2 * 2]
+
+ if time_matches:
+ time_matches_converted = []
+ for t in time_matches:
+ parts = t.split(":")
+ if len(parts) == 3:
+ h, m = map(int, parts[:2])
+ s = float(parts[2])
+ time_in_sec = h * 3600 + m * 60 + s
+ elif len(parts) == 2:
+ m = int(parts[0])
+ s = float(parts[1])
+ time_in_sec = m * 60 + s
+ time_matches_converted.append(float(time_in_sec))
+ timestamps = [
+ (time_matches_converted[i], time_matches_converted[i + 1])
+ for i in range(0, len(time_matches_converted), 2)
+ ]
+
+ if len(timestamps) == 0:
+ patterns = [
+ r"(\d+\.?\d*)\s*-\s*(\d+\.?\d*)",
+ r"(\d+\.?\d*)\s+to\s+(\d+\.?\d*)",
+ ]
+ for time_pattern in patterns:
+ time_matches = re.findall(time_pattern, paragraph)
+ if time_matches:
+ timestamps = [(float(s), float(e)) for s, e in time_matches]
+ break
+
+ if len(timestamps) == 0:
+ time_regex = re.compile(r"\b(\d+\.\d+|\d+)\b")
+ time_matches = re.findall(time_regex, paragraph)
+ time_matches = time_matches[: len(time_matches) // 2 * 2]
+ timestamps = [
+ (float(time_matches[i]), float(time_matches[i + 1]))
+ for i in range(0, len(time_matches), 2)
+ ]
+
+ return timestamps
+
+
+def compute_iou(a, b):
+ max0 = max(a[0], b[0])
+ min0 = min(a[0], b[0])
+ max1 = max(a[1], b[1])
+ min1 = min(a[1], b[1])
+ return max(min1 - max0, 0) / (max1 - min0) if (max1 - min0) > 0 else 0.0
+
+
+# ---------------------------------------------------------------------------
+# Data loading (self-contained, no timelens package needed)
+# ---------------------------------------------------------------------------
+
+def parse_query(query):
+ return re.sub(r"\s+", " ", query).strip().strip(".").strip()
+
+
+DATASET_CONFIGS = {
+ "charades-timelens": {
+ "anno": "charades-timelens.json",
+ "video_subdir": "video_shards/charades",
+ },
+ "activitynet-timelens": {
+ "anno": "activitynet-timelens.json",
+ "video_subdir": "video_shards/activitynet",
+ },
+ "qvhighlights-timelens": {
+ "anno": "qvhighlights-timelens.json",
+ "video_subdir": "video_shards/qvhighlights",
+ },
+}
+
+
+def load_annotations(bench_dir, dataset_name):
+ canonical_path = Path(bench_dir) / f"{dataset_name}.jsonl"
+ if canonical_path.is_file():
+ annos = []
+ for row in load_json_records(canonical_path):
+ span = row.get("span", row.get("answer"))
+ if (
+ isinstance(span, (list, tuple))
+ and len(span) == 2
+ and not isinstance(span[0], (list, tuple))
+ ):
+ span = [list(span)]
+ annos.append(
+ {
+ "video_path": row.get("video_path") or row.get("path"),
+ "duration": row.get("duration", 0),
+ "query": parse_query(row.get("problem", "")),
+ "span": span,
+ }
+ )
+ return annos
+
+ cfg = DATASET_CONFIGS[dataset_name]
+ anno_path = os.path.join(bench_dir, cfg["anno"])
+ video_root = os.path.join(bench_dir, cfg["video_subdir"])
+
+ with open(anno_path, "r") as f:
+ raw = json.load(f)
+
+ annos = []
+ for vid, info in raw.items():
+ video_path = os.path.join(video_root, vid + ".mp4")
+ duration = info.get("duration", 0)
+ queries = info.get("queries", info.get("sentences", []))
+ spans = info.get("spans", info.get("timestamps", []))
+ for span, query in zip(spans, queries):
+ annos.append(dict(
+ video_path=video_path,
+ duration=duration,
+ query=parse_query(query),
+ span=[span] if not isinstance(span[0], (list, tuple)) else span,
+ ))
+ return annos
+
+
+# ---------------------------------------------------------------------------
+# Dataset (aligned with TimeLens evaluation/utils.py)
+# ---------------------------------------------------------------------------
+
+class GroundingDataset(Dataset):
+ def __init__(self, annos, processor, args):
+ super().__init__()
+ self.annos = annos
+ self.processor = processor
+ self.args = args
+
+ model_lower = args.model_path.lower()
+ if getattr(args, "prompt_mode", "same") == "timelens_official":
+ self.prompt = TIMELENS_OFFICIAL_PROMPT
+ else:
+ # Default remains aligned with data/joint/sft_joint_all.jsonl.
+ self.prompt = PROMPT_WO_THINK
+
+ if "qwen3" in model_lower or "timelens-8b" in model_lower:
+ self.downsample_rate = 32
+ self.model_family = "qwen3"
+ elif "qwen2" in model_lower or "timelens-7b" in model_lower:
+ self.downsample_rate = 28
+ self.model_family = "qwen2"
+ else:
+ self.downsample_rate = 32
+ self.model_family = "qwen3"
+
+ def __len__(self):
+ return len(self.annos)
+
+ def __getitem__(self, index):
+ anno = copy.deepcopy(self.annos[index])
+ video_path = anno["video_path"]
+ query = anno["query"]
+ dr = self.downsample_rate
+
+ messages = [{
+ "role": "user",
+ "content": [
+ {
+ "type": "video",
+ "video": video_path,
+ "min_pixels": self.args.min_tokens * dr * dr,
+ "max_pixels": self.args.max_pixels,
+ "total_pixels": self.args.total_tokens * dr * dr,
+ "max_frames": self.args.max_frames,
+ "fps": self.args.fps,
+ },
+ {"type": "text", "text": self.prompt.format(query)},
+ ],
+ }]
+
+ chat_kwargs = dict(tokenize=False, add_generation_prompt=True,
+ enable_thinking=self.args.enable_thinking)
+ text = self.processor.apply_chat_template(messages, **chat_kwargs)
+
+ if self.model_family == "qwen3":
+ images, videos, video_kwargs = process_vision_info(
+ messages,
+ image_patch_size=16,
+ return_video_kwargs=True,
+ return_video_metadata=True,
+ )
+ videos, video_metadatas = zip(*videos)
+ videos, video_metadatas = list(videos), list(video_metadatas)
+ inputs = self.processor(
+ text=[text],
+ images=images,
+ videos=videos,
+ video_metadata=video_metadatas,
+ padding=True,
+ return_tensors="pt",
+ **video_kwargs,
+ )
+ else:
+ images, videos, video_kwargs = process_vision_info(
+ messages, return_video_kwargs=True
+ )
+ inputs = self.processor(
+ text=[text],
+ images=images,
+ videos=videos,
+ padding=True,
+ return_tensors="pt",
+ **video_kwargs,
+ )
+
+ return {
+ "inputs": inputs,
+ "anno": anno,
+ "prompt": text,
+ "prompt_template": self.prompt,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def set_seed(seed):
+ random.seed(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ if torch.cuda.is_available():
+ torch.cuda.manual_seed_all(seed)
+
+
+def parse_args():
+ p = argparse.ArgumentParser(description="TimeLens-Bench eval with HuggingFace")
+ p.add_argument("--model_path", required=True)
+ p.add_argument("--bench_dir", required=True)
+ p.add_argument(
+ "--dataset",
+ required=True,
+ help="Legacy TimeLens name or a canonical snake_case split.",
+ )
+ p.add_argument("--output_dir", required=True)
+ p.add_argument("--enable_thinking", default="false", choices=["true", "false"])
+ p.add_argument(
+ "--prompt_mode",
+ default="same",
+ choices=["same", "timelens_official"],
+ help="same: training-aligned prompt; "
+ "timelens_official: exact official TimeLens prompt",
+ )
+ p.add_argument("--min_tokens", type=int, default=1)
+ p.add_argument("--total_tokens", type=int, default=128000)
+ p.add_argument("--max_pixels", type=int, default=409600)
+ p.add_argument("--max_frames", type=int, default=2048)
+ p.add_argument("--fps", type=int, default=4)
+ p.add_argument("--max_new_tokens", type=int, default=128)
+ p.add_argument("--repetition_penalty", type=float, default=1.0)
+ p.add_argument("--temperature", type=float, default=0.0)
+ p.add_argument("--top_p", type=float, default=1.0)
+ p.add_argument("--num_return_sequences", type=int, default=1)
+ p.add_argument(
+ "--max_samples",
+ type=int,
+ default=0,
+ help="Maximum prompts in this shard; 0 evaluates the full shard.",
+ )
+ p.add_argument(
+ "--stop_after_answer",
+ default="true",
+ choices=["true", "false"],
+ help="Stop decoding immediately after the first generated .",
+ )
+ p.add_argument("--chunk", type=int, default=1)
+ p.add_argument("--index", type=int, default=0)
+ p.add_argument("--seed", type=int, default=42)
+ p.add_argument("--num_workers", type=int, default=2)
+ p.add_argument("--compile", action="store_true", help="Use torch.compile for faster inference")
+ p.add_argument("--processor_path", default=None, help="Path to load processor from (defaults to model_path)")
+ args = p.parse_args()
+ args.enable_thinking = args.enable_thinking == "true"
+ args.stop_after_answer = args.stop_after_answer == "true"
+ if args.num_return_sequences < 1:
+ p.error("--num_return_sequences must be >= 1")
+ if args.num_return_sequences > 1 and args.temperature <= 0:
+ p.error("--num_return_sequences > 1 requires --temperature > 0")
+ if args.processor_path is None:
+ args.processor_path = args.model_path
+ return args
+
+
+def main():
+ args = parse_args()
+ canonical_profile = canonical_dataset_record("temporal_grounding", args.dataset)
+ if canonical_profile is not None:
+ preprocessing = canonical_profile.get("preprocessing", {})
+ if isinstance(preprocessing, dict):
+ for name in (
+ "fps",
+ "min_tokens",
+ "max_frames",
+ "max_pixels",
+ "total_tokens",
+ ):
+ if name in preprocessing:
+ setattr(args, name, preprocessing[name])
+ set_seed(args.seed)
+
+ os.makedirs(args.output_dir, exist_ok=True)
+ result_path = os.path.join(
+ args.output_dir,
+ f"results_{args.dataset}_shard{args.index}.json",
+ )
+
+ print(f"Model: {args.model_path}")
+ print(f"Dataset: {args.dataset} | Chunk {args.index}/{args.chunk}")
+ print(
+ f"Thinking: {args.enable_thinking} | Prompt mode: {args.prompt_mode} "
+ f"| FPS: {args.fps}"
+ )
+ print(f"Tokens: min={args.min_tokens}, total={args.total_tokens}")
+ print(f"Video: max_pixels={args.max_pixels}, max_frames={args.max_frames}")
+ print(f"Output: {result_path}")
+
+ # Load model
+ print("\nLoading model ...")
+ if not torch.cuda.is_available():
+ raise RuntimeError(
+ "CUDA is required for TimeLens HF evaluation because inputs are moved "
+ "to CUDA and FlashAttention2 is enabled."
+ )
+ model = AutoModelForImageTextToText.from_pretrained(
+ args.model_path,
+ dtype=torch.bfloat16,
+ attn_implementation="flash_attention_2",
+ device_map={"": "cuda:0"},
+ ).eval()
+
+ if args.compile:
+ print("Compiling model with torch.compile ...")
+ model = torch.compile(model, mode="reduce-overhead")
+
+ processor = AutoProcessor.from_pretrained(
+ args.processor_path,
+ padding_side="left",
+ do_resize=False,
+ trust_remote_code=True,
+ )
+ # Load data
+ annos = load_annotations(args.bench_dir, args.dataset)
+ annos.sort(key=lambda x: x["duration"], reverse=True)
+ annos = annos[args.index :: args.chunk]
+ if args.max_samples > 0:
+ annos = annos[: args.max_samples]
+ print(f"Loaded {len(annos)} samples (shard {args.index}/{args.chunk})")
+
+ dataset = GroundingDataset(annos, processor, args)
+ loader = DataLoader(
+ dataset,
+ batch_size=1,
+ shuffle=False,
+ num_workers=args.num_workers,
+ prefetch_factor=2 if args.num_workers > 0 else None,
+ pin_memory=True,
+ collate_fn=lambda x: x[0],
+ )
+
+ # Inference
+ results = []
+ ious = []
+ recall = {0.3: 0, 0.5: 0, 0.7: 0}
+ t0 = time.time()
+
+ data_wait = 0.0
+ gen_total = 0.0
+ sample_idx = 0
+ iter_start = time.time()
+
+ for data in tqdm(loader, desc=f"shard-{args.index}"):
+ t_data = time.time()
+ data_elapsed = t_data - iter_start
+ data_wait += data_elapsed
+
+ inputs = data["inputs"].to("cuda", non_blocking=True)
+ anno = data["anno"]
+ prompt = data["prompt"]
+ prompt_template = data["prompt_template"]
+ duration = anno["duration"]
+ span = anno["span"]
+ if isinstance(span[0], (list, tuple)):
+ span = span[0]
+
+ input_len = inputs.input_ids.shape[-1]
+ stopping_criteria = None
+ if args.stop_after_answer:
+ stopping_criteria = StoppingCriteriaList([
+ StopOnDecodedText(processor.tokenizer, input_len, " ")
+ ])
+
+ t_gen = time.time()
+ do_sample = args.temperature > 0
+ output_ids = model.generate(
+ **inputs,
+ do_sample=do_sample,
+ temperature=args.temperature if do_sample else None,
+ top_p=args.top_p if do_sample else None,
+ top_k=None,
+ num_return_sequences=args.num_return_sequences,
+ max_new_tokens=args.max_new_tokens,
+ repetition_penalty=args.repetition_penalty,
+ stopping_criteria=stopping_criteria,
+ )
+ gen_elapsed = time.time() - t_gen
+ gen_total += gen_elapsed
+
+ trimmed = [out[input_len:] for out in output_ids]
+ answers = processor.batch_decode(
+ trimmed,
+ skip_special_tokens=True,
+ clean_up_tokenization_spaces=False,
+ )
+ if args.stop_after_answer:
+ answers = [truncate_after_first_answer(answer) for answer in answers]
+
+ sample_idx += 1
+ if sample_idx <= 30 or sample_idx % 50 == 0:
+ print(f" [{sample_idx}] data={data_elapsed:.1f}s gen={gen_elapsed:.1f}s "
+ f"in={input_len} n={len(answers)} out={trimmed[0].shape[0]} | {answers[0][:80]}",
+ flush=True)
+
+ for rollout_index, answer in enumerate(answers):
+ timestamps = extract_time(answer)
+ parsed = bool(timestamps)
+ if not timestamps:
+ timestamps = [(duration + 10, duration + 20)]
+ timestamps = [(round(s), round(e)) for s, e in timestamps]
+ pred = timestamps[0]
+
+ iou_val = compute_iou(span, pred)
+ ious.append(iou_val)
+ for t in recall:
+ if iou_val >= t:
+ recall[t] += 1
+
+ results.append({
+ "video": os.path.basename(anno["video_path"]),
+ "query": anno["query"],
+ "gt_span": span,
+ "pred_span": list(pred),
+ "iou": round(iou_val, 4),
+ "answer": answer,
+ "parsed": parsed,
+ "rollout_index": rollout_index,
+ "prompt": prompt,
+ "prompt_template": prompt_template,
+ "duration": duration,
+ })
+
+ if sample_idx % 50 == 0 or sample_idx == len(dataset):
+ n_cur = len(ious)
+ cur_metrics = {
+ "num_samples": n_cur,
+ "mIoU": round(sum(ious) / n_cur * 100, 2),
+ }
+ for t in [0.3, 0.5, 0.7]:
+ cur_metrics[f"R@{t}"] = round(recall[t] / n_cur * 100, 2)
+ print(f" [checkpoint {sample_idx}/{len(dataset)}] "
+ f"mIoU={cur_metrics['mIoU']:.2f}% R@0.3={cur_metrics['R@0.3']:.2f}% "
+ f"R@0.5={cur_metrics['R@0.5']:.2f}% R@0.7={cur_metrics['R@0.7']:.2f}%",
+ flush=True)
+ with open(result_path, "w") as f:
+ json.dump(results, f, ensure_ascii=False, indent=2)
+
+ iter_start = time.time()
+
+ elapsed = time.time() - t0
+ n = len(ious)
+ print(f"\nInference done: {n} samples in {elapsed:.1f}s ({n / elapsed:.1f} samples/s)")
+ print(f" data_wait={data_wait:.1f}s gen={gen_total:.1f}s "
+ f"avg_data={data_wait/max(1, sample_idx):.2f}s "
+ f"avg_gen={gen_total/max(1, sample_idx):.2f}s")
+
+ # Metrics
+ metrics = {
+ "num_samples": n,
+ "mIoU": round(sum(ious) / n * 100, 2) if n else 0,
+ }
+ for t in [0.3, 0.5, 0.7]:
+ metrics[f"R@{t}"] = round(recall[t] / n * 100, 2) if n else 0
+ n_parsed = sum(1 for r in results if r.get("parsed", False))
+ metrics["parse_rate"] = round(n_parsed / n * 100, 2) if n else 0
+
+ print(f" mIoU={metrics['mIoU']:.2f}% R@0.3={metrics['R@0.3']:.2f}% "
+ f"R@0.5={metrics['R@0.5']:.2f}% R@0.7={metrics['R@0.7']:.2f}% "
+ f"Parse={metrics['parse_rate']:.2f}%")
+
+ # Save
+ with open(result_path, "w") as f:
+ json.dump(results, f, ensure_ascii=False, indent=2)
+
+ summary_path = os.path.join(
+ args.output_dir,
+ f"summary_shard{args.index}.json",
+ )
+ with open(summary_path, "w") as f:
+ json.dump({args.dataset: metrics}, f, ensure_ascii=False, indent=2)
+
+ print(f"Results -> {result_path}")
+ print(f"Summary -> {summary_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/eval/task/temporal_grounding/run_eval.sh b/code/eval/task/temporal_grounding/run_eval.sh
new file mode 100644
index 0000000000000000000000000000000000000000..d13a1896dafcdb01a99bde7fddaf773b54286695
--- /dev/null
+++ b/code/eval/task/temporal_grounding/run_eval.sh
@@ -0,0 +1,408 @@
+#!/bin/bash
+# =============================================================================
+# TimeLens-Bench Evaluation — HuggingFace inference (multi-GPU data parallel)
+#
+# Usage:
+# bash eval/run_eval.sh [MODEL_PATH] [ENABLE_THINKING] [OUTPUT_DIR]
+#
+# Examples:
+# # Base model evaluation
+# bash eval/run_eval.sh /path/to/Qwen3.5-9B
+#
+# # Trained checkpoint evaluation
+# bash eval/run_eval.sh /path/to/checkpoint/huggingface
+#
+# # With thinking mode
+# bash eval/run_eval.sh /path/to/model true
+# =============================================================================
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+# eval/task/temporal_grounding/run_eval.sh → repository root (up 3 levels)
+PROJECT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+
+# ---------- paths ----------
+MODEL_PATH="${1:-}"
+ENABLE_THINKING="${2:-false}"
+BENCH_DIR="${TIMELENS_BENCH_DIR:-${PROJECT_DIR}/data/eval/temporal_grounding}"
+
+if [[ -z "$MODEL_PATH" || ! -d "$MODEL_PATH" ]]; then
+ echo "ERROR: MODEL_PATH must name an existing model directory." >&2
+ exit 2
+fi
+if [[ ! -d "$BENCH_DIR" ]]; then
+ echo "ERROR: TIMELENS_BENCH_DIR does not exist: $BENCH_DIR" >&2
+ exit 2
+fi
+
+# ---------- eval settings ----------
+DATASETS="${DATASETS:-charades-timelens}"
+SETTING="${SETTING:-timelens-fps${FPS:-4}-min${MIN_TOKENS:-1}-total${TOTAL_TOKENS:-128000}-new${MAX_NEW_TOKENS:-128}-${DATASETS//,/_}}"
+MIN_TOKENS="${MIN_TOKENS:-1}"
+TOTAL_TOKENS="${TOTAL_TOKENS:-128000}"
+MAX_PIXELS="${MAX_PIXELS:-409600}"
+MAX_FRAMES="${MAX_FRAMES:-2048}"
+FPS="${FPS:-4}"
+MAX_NEW_TOKENS="${MAX_NEW_TOKENS:-512}"
+REPETITION_PENALTY="${REPETITION_PENALTY:-1.0}"
+STOP_AFTER_ANSWER="${STOP_AFTER_ANSWER:-true}"
+PROMPT_MODE="${PROMPT_MODE:-same}"
+NUM_WORKERS="${NUM_WORKERS:-2}"
+MAX_SAMPLES="${TIMELENS_MAX_SAMPLES:-0}"
+RESUME="${TIMELENS_RESUME:-1}"
+MERGE_SHARDS="${TIMELENS_MERGE_SHARDS:-1}"
+if ! [[ "$MAX_SAMPLES" =~ ^[0-9]+$ ]]; then
+ echo "ERROR: TIMELENS_MAX_SAMPLES must be a non-negative integer." >&2
+ exit 1
+fi
+
+# ---------- output dir layout ----------
+# Default layout:
+# ///
+#
+# If TIMELENS_USE_OUTPUT_ROOT_DIRECT=1, OUTPUT_ROOT is assumed to already be
+# the full run directory and each dataset is written under:
+# //
+MODEL_TAG=$(basename "${MODEL_PATH%/}")
+TIMESTAMP=$(date +%Y%m%d_%H%M%S)
+OUTPUT_ROOT="${3:-${OUTPUT_ROOT:-${PROJECT_DIR}/outputs/temporal_grounding}}"
+RUN_TAG="eval_hf-${MODEL_TAG}-${TIMESTAMP}"
+if [ "$ENABLE_THINKING" = "true" ]; then
+ RUN_TAG="${RUN_TAG}-think"
+fi
+if [ "${NO_ANSWER_WRAP:-0}" = "1" ]; then
+ RUN_TAG="${RUN_TAG}-naw"
+fi
+
+# ---------- hardware ----------
+# PyTorch wheels bundle CUDA user-space libraries. Prefer those libraries so a
+# cluster-wide CUDA toolkit cannot override them with an older nvJitLink ABI.
+if [ "${TIMELENS_USE_SYSTEM_CUDA:-0}" = "1" ]; then
+ if [ -z "${CUDA_HOME:-}" ] || [ ! -d "$CUDA_HOME" ]; then
+ echo "ERROR: TIMELENS_USE_SYSTEM_CUDA=1 requires a valid CUDA_HOME." >&2
+ exit 1
+ fi
+ export CUDA_PATH="${CUDA_PATH:-$CUDA_HOME}"
+ export PATH="$CUDA_HOME/bin:$PATH"
+ export LD_LIBRARY_PATH="$CUDA_HOME/lib64:${LD_LIBRARY_PATH:-}"
+else
+ PYTHON_NVIDIA_LIBS="$(python - <<'PY'
+from pathlib import Path
+import sysconfig
+
+root = Path(sysconfig.get_path("purelib")) / "nvidia"
+print(":".join(str(path) for path in sorted(root.glob("*/lib")) if path.is_dir()))
+PY
+)"
+ PYTHON_RUNTIME_LIBS="$PYTHON_NVIDIA_LIBS"
+ if [ -n "${CONDA_PREFIX:-}" ] && [ -d "$CONDA_PREFIX/lib" ]; then
+ PYTHON_RUNTIME_LIBS="${PYTHON_RUNTIME_LIBS:+$PYTHON_RUNTIME_LIBS:}$CONDA_PREFIX/lib"
+ fi
+ if [ -n "$PYTHON_RUNTIME_LIBS" ]; then
+ export LD_LIBRARY_PATH="$PYTHON_RUNTIME_LIBS${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+ fi
+fi
+
+IFS="," read -ra GPULIST <<< "${CUDA_VISIBLE_DEVICES:-$(seq -s, 0 $(($(nvidia-smi -L | wc -l)-1)))}"
+NUM_GPUS=${#GPULIST[@]}
+NUM_SHARDS="${TIMELENS_GLOBAL_SHARD_COUNT:-${TIMELENS_NUM_SHARDS:-$NUM_GPUS}}"
+LOCAL_SHARDS="${TIMELENS_LOCAL_SHARD_COUNT:-$NUM_SHARDS}"
+SHARD_OFFSET="${TIMELENS_GLOBAL_SHARD_OFFSET:-0}"
+if ! [[ "$NUM_SHARDS" =~ ^[0-9]+$ ]] || [ "$NUM_SHARDS" -lt 1 ]; then
+ echo "ERROR: TIMELENS_NUM_SHARDS must be a positive integer, got '$NUM_SHARDS'" >&2
+ exit 1
+fi
+if ! [[ "$LOCAL_SHARDS" =~ ^[0-9]+$ ]] || [ "$LOCAL_SHARDS" -lt 1 ]; then
+ echo "ERROR: TIMELENS_LOCAL_SHARD_COUNT must be positive, got '$LOCAL_SHARDS'" >&2
+ exit 1
+fi
+if ! [[ "$SHARD_OFFSET" =~ ^[0-9]+$ ]] || \
+ [ $((SHARD_OFFSET + LOCAL_SHARDS)) -gt "$NUM_SHARDS" ]; then
+ echo "ERROR: invalid shard range offset=$SHARD_OFFSET count=$LOCAL_SHARDS global=$NUM_SHARDS" >&2
+ exit 1
+fi
+for GPU_ID in "${GPULIST[@]}"; do
+ if [[ ! "$GPU_ID" =~ ^[0-9]+$ ]]; then
+ echo "ERROR: CUDA_VISIBLE_DEVICES must contain comma-separated GPU ids, got '${CUDA_VISIBLE_DEVICES:-}'" >&2
+ exit 1
+ fi
+done
+
+echo "=============================================="
+echo "TimeLens-Bench Evaluation (HuggingFace)"
+echo "=============================================="
+echo "Model: $MODEL_PATH"
+echo "Thinking: $ENABLE_THINKING"
+echo "Prompt mode:$PROMPT_MODE"
+echo "NoAnswerWrap: ${NO_ANSWER_WRAP:-0}"
+echo "Datasets: $DATASETS"
+echo "GPUs: ${GPULIST[*]} (${NUM_GPUS} total)"
+echo "Shards: global=$NUM_SHARDS local=$LOCAL_SHARDS offset=$SHARD_OFFSET"
+echo "Tokens: min=$MIN_TOKENS, total=$TOTAL_TOKENS"
+echo "Video: max_pixels=$MAX_PIXELS, max_frames=$MAX_FRAMES"
+echo "FPS: $FPS"
+echo "Decode: max_new=$MAX_NEW_TOKENS, repetition_penalty=$REPETITION_PENALTY, stop_after_answer=$STOP_AFTER_ANSWER"
+echo "Max samples:$MAX_SAMPLES per shard (0 means all)"
+echo "Output root:$OUTPUT_ROOT"
+echo "Run tag: $RUN_TAG"
+echo "=============================================="
+
+mkdir -p "$OUTPUT_ROOT"
+
+# ---------- cleanup handler ----------
+PIDS=()
+cleanup() {
+ echo ""
+ echo "Caught interrupt, killing all workers ..."
+ for pid in "${PIDS[@]}"; do
+ kill -TERM "$pid" 2>/dev/null || true
+ done
+ wait 2>/dev/null
+ echo "All workers killed."
+ exit 1
+}
+trap cleanup INT TERM
+
+# ---------- run evaluation for each dataset ----------
+IFS=',' read -ra DATASET_LIST <<< "$DATASETS"
+
+for DATASET in "${DATASET_LIST[@]}"; do
+ if [ "${TIMELENS_USE_OUTPUT_ROOT_DIRECT:-0}" = "1" ]; then
+ OUTPUT_DIR="${OUTPUT_ROOT}/${DATASET}"
+ else
+ OUTPUT_DIR="${OUTPUT_ROOT}/${DATASET}/${RUN_TAG}"
+ fi
+ mkdir -p "$OUTPUT_DIR"
+ if [ "$SHARD_OFFSET" -eq 0 ]; then
+ SETTING="$SETTING" DATASET="$DATASET" OUTPUT_DIR="$OUTPUT_DIR" MODEL_PATH="$MODEL_PATH" \
+ PROJECT_DIR="$PROJECT_DIR" \
+ ENABLE_THINKING="$ENABLE_THINKING" BENCH_DIR="$BENCH_DIR" GPUS="${GPULIST[*]}" \
+ NUM_GPUS="$NUM_GPUS" NUM_SHARDS="$NUM_SHARDS" LOCAL_SHARDS="$LOCAL_SHARDS" \
+ SHARD_OFFSET="$SHARD_OFFSET" MIN_TOKENS="$MIN_TOKENS" TOTAL_TOKENS="$TOTAL_TOKENS" \
+ MAX_PIXELS="$MAX_PIXELS" MAX_FRAMES="$MAX_FRAMES" FPS="$FPS" \
+ MAX_NEW_TOKENS="$MAX_NEW_TOKENS" REPETITION_PENALTY="$REPETITION_PENALTY" \
+ STOP_AFTER_ANSWER="$STOP_AFTER_ANSWER" PROMPT_MODE="$PROMPT_MODE" \
+ NUM_WORKERS="$NUM_WORKERS" MAX_SAMPLES="$MAX_SAMPLES" \
+ python - <<'PY'
+import json
+import os
+import sys
+
+project_dir = os.environ["PROJECT_DIR"]
+sys.path.insert(0, os.path.join(project_dir, "eval", "task"))
+try:
+ from eval_prompt import PROMPT_WO_THINK, TIMELENS_OFFICIAL_PROMPT
+except Exception:
+ PROMPT_WO_THINK = None
+ TIMELENS_OFFICIAL_PROMPT = None
+
+prompt_mode = os.environ["PROMPT_MODE"]
+prompt_template = (
+ TIMELENS_OFFICIAL_PROMPT
+ if prompt_mode == "timelens_official"
+ else PROMPT_WO_THINK
+)
+
+config = {
+ "task": "temporal_grounding",
+ "setting": os.environ["SETTING"],
+ "model_path": os.environ["MODEL_PATH"],
+ "dataset": os.environ["DATASET"],
+ "bench_dir": os.environ["BENCH_DIR"],
+ "output_dir": os.environ["OUTPUT_DIR"],
+ "enable_thinking": os.environ["ENABLE_THINKING"],
+ "gpus": os.environ["GPUS"].split(),
+ "num_gpus": int(os.environ["NUM_GPUS"]),
+ "video": {
+ "min_tokens": int(os.environ["MIN_TOKENS"]),
+ "total_tokens": int(os.environ["TOTAL_TOKENS"]),
+ "max_pixels": int(os.environ["MAX_PIXELS"]),
+ "max_frames": int(os.environ["MAX_FRAMES"]),
+ "fps": float(os.environ["FPS"]),
+ },
+ "decode": {
+ "max_new_tokens": int(os.environ["MAX_NEW_TOKENS"]),
+ "repetition_penalty": float(os.environ["REPETITION_PENALTY"]),
+ "stop_after_answer": os.environ["STOP_AFTER_ANSWER"],
+ },
+ "runtime": {
+ "num_shards": int(os.environ["NUM_SHARDS"]),
+ "local_shards": int(os.environ["LOCAL_SHARDS"]),
+ "shard_offset": int(os.environ["SHARD_OFFSET"]),
+ "num_workers": int(os.environ["NUM_WORKERS"]),
+ "max_samples_per_shard": int(os.environ["MAX_SAMPLES"]),
+ },
+ "prompt": {
+ "mode": prompt_mode,
+ "source": (
+ "eval/task/eval_prompt.py:TIMELENS_OFFICIAL_PROMPT"
+ if prompt_mode == "timelens_official"
+ else "eval/task/eval_prompt.py:PROMPT_WO_THINK"
+ ),
+ "template": prompt_template,
+ },
+}
+with open(os.path.join(os.environ["OUTPUT_DIR"], "run_config.json"), "w", encoding="utf-8") as f:
+ json.dump(config, f, ensure_ascii=False, indent=2)
+PY
+ fi
+ echo ""
+ echo ">>> Evaluating: $DATASET"
+ echo " Output: $OUTPUT_DIR"
+ PIDS=()
+ SHARD_IDS=()
+
+ for LOCAL_IDX in $(seq 0 $((LOCAL_SHARDS - 1))); do
+ IDX=$((SHARD_OFFSET + LOCAL_IDX))
+ RESULT_PATH="$OUTPUT_DIR/results_${DATASET}_shard${IDX}.json"
+ SUMMARY_PATH="$OUTPUT_DIR/summary_shard${IDX}.json"
+ if [ "$RESUME" = "1" ] && [ -s "$RESULT_PATH" ] && [ -s "$SUMMARY_PATH" ]; then
+ echo " [resume] Skipping completed shard $IDX/$NUM_SHARDS"
+ continue
+ fi
+ GPU_ID="${GPULIST[$((LOCAL_IDX % NUM_GPUS))]}"
+ (
+ export CUDA_VISIBLE_DEVICES=$GPU_ID
+ export PYTHONUNBUFFERED=1
+ python - <<'PY'
+import os
+import torch
+
+print(f"Worker CUDA_VISIBLE_DEVICES: {os.environ.get('CUDA_VISIBLE_DEVICES')}", flush=True)
+print(f"Worker torch: {torch.__version__}", flush=True)
+print(f"Worker torch cuda: {torch.version.cuda}", flush=True)
+print(f"Worker cuda available: {torch.cuda.is_available()}", flush=True)
+print(f"Worker device count: {torch.cuda.device_count()}", flush=True)
+PY
+ python "${SCRIPT_DIR}/eval_timelens_hf.py" \
+ --model_path "$MODEL_PATH" \
+ --bench_dir "$BENCH_DIR" \
+ --dataset "$DATASET" \
+ --output_dir "$OUTPUT_DIR" \
+ --enable_thinking "$ENABLE_THINKING" \
+ --prompt_mode "$PROMPT_MODE" \
+ --min_tokens $MIN_TOKENS \
+ --total_tokens $TOTAL_TOKENS \
+ --max_pixels $MAX_PIXELS \
+ --max_frames $MAX_FRAMES \
+ --fps $FPS \
+ --max_new_tokens $MAX_NEW_TOKENS \
+ --repetition_penalty $REPETITION_PENALTY \
+ --stop_after_answer "$STOP_AFTER_ANSWER" \
+ --num_workers $NUM_WORKERS \
+ --max_samples $MAX_SAMPLES \
+ --chunk $NUM_SHARDS \
+ --index $IDX
+ ) > "$OUTPUT_DIR/worker_${DATASET}_${IDX}.log" 2>&1 &
+ PIDS+=($!)
+ SHARD_IDS+=($IDX)
+ echo " Launched shard $IDX on GPU $GPU_ID (PID ${PIDS[-1]})"
+ done
+
+ echo " Waiting for ${#PIDS[@]} local workers ..."
+ FAILED=0
+ for i in "${!PIDS[@]}"; do
+ if wait "${PIDS[$i]}"; then
+ echo " [DONE] shard ${SHARD_IDS[$i]} (PID ${PIDS[$i]})"
+ else
+ RC=$?
+ echo " [FAIL] shard ${SHARD_IDS[$i]} (PID ${PIDS[$i]}) exited with code $RC"
+ FAILED=1
+ fi
+ done
+
+ if [ $FAILED -ne 0 ]; then
+ echo " Some workers failed. Check logs: $OUTPUT_DIR/worker_${DATASET}_*.log"
+ exit 1
+ fi
+ if [ "$MERGE_SHARDS" != "1" ]; then
+ echo " Local shard range completed; centralized merge disabled."
+ continue
+ fi
+
+ # ---------- merge shard results ----------
+ echo " Merging results ..."
+ python -c "
+import json, os, sys
+
+output_dir = sys.argv[1]
+dataset = sys.argv[2]
+num_shards = int(sys.argv[3])
+
+all_samples = []
+for sid in range(num_shards):
+ path = os.path.join(output_dir, f'results_{dataset}_shard{sid}.json')
+ if os.path.isfile(path):
+ with open(path) as f:
+ all_samples.extend(json.load(f))
+
+n = len(all_samples)
+if n == 0:
+ print(f' No results for {dataset}')
+ sys.exit(0)
+
+ious = [s['iou'] for s in all_samples]
+thresholds = [0.3, 0.5, 0.7]
+metrics = {
+ 'num_samples': n,
+ 'mIoU': round(sum(ious) / n * 100, 2),
+}
+for t in thresholds:
+ metrics[f'R@{t}'] = round(sum(1 for x in ious if x >= t) / n * 100, 2)
+n_parsed = sum(1 for s in all_samples if s.get('pred_span') is not None)
+metrics['parse_rate'] = round(n_parsed / n * 100, 2)
+
+print(f' {dataset}: n={n} mIoU={metrics[\"mIoU\"]:.2f}% R@0.3={metrics[\"R@0.3\"]:.2f}% R@0.5={metrics[\"R@0.5\"]:.2f}% R@0.7={metrics[\"R@0.7\"]:.2f}% Parse={metrics[\"parse_rate\"]:.2f}%')
+
+# Save merged results
+merged_path = os.path.join(output_dir, f'results_{dataset}.json')
+with open(merged_path, 'w') as f:
+ json.dump(all_samples, f, ensure_ascii=False, indent=2)
+
+# Save/update summary
+summary_path = os.path.join(output_dir, 'summary.json')
+summary = {}
+if os.path.isfile(summary_path):
+ with open(summary_path) as f:
+ summary = json.load(f)
+summary[dataset] = metrics
+with open(summary_path, 'w') as f:
+ json.dump(summary, f, ensure_ascii=False, indent=2)
+
+# Cleanup shard files. Keep worker logs so per-sample generation traces
+# survive the merge; only the redundant shard JSONs (already folded into
+# results_{dataset}.json) are removed. Set TIMELENS_KEEP_SHARDS=1 to keep all.
+keep_shards = os.environ.get('TIMELENS_KEEP_SHARDS', '0') == '1'
+if not keep_shards:
+ for sid in range(num_shards):
+ for pattern in [f'results_{dataset}_shard{sid}.json',
+ f'summary_shard{sid}.json']:
+ p = os.path.join(output_dir, pattern)
+ if os.path.isfile(p):
+ os.remove(p)
+" "$OUTPUT_DIR" "$DATASET" "$NUM_SHARDS"
+
+done
+
+echo ""
+echo "=============================================="
+echo "All evaluations complete!"
+echo "Output root: $OUTPUT_ROOT"
+echo "Run tag: $RUN_TAG"
+echo "=============================================="
+
+# Print each dataset's summary, in order.
+for DATASET in "${DATASET_LIST[@]}"; do
+ if [ "${TIMELENS_USE_OUTPUT_ROOT_DIRECT:-0}" = "1" ]; then
+ SUMMARY="${OUTPUT_ROOT}/${DATASET}/summary.json"
+ else
+ SUMMARY="${OUTPUT_ROOT}/${DATASET}/${RUN_TAG}/summary.json"
+ fi
+ if [ -f "$SUMMARY" ]; then
+ echo ""
+ echo "[${DATASET}] -> $SUMMARY"
+ cat "$SUMMARY"
+ fi
+done
+echo ""
diff --git a/code/eval/task/tracking/eval_tracking_vllm.py b/code/eval/task/tracking/eval_tracking_vllm.py
new file mode 100644
index 0000000000000000000000000000000000000000..f6a46585c93a1b1035cad774881353045a3cfb65
--- /dev/null
+++ b/code/eval/task/tracking/eval_tracking_vllm.py
@@ -0,0 +1,1000 @@
+#!/usr/bin/env python3
+"""
+Tracking evaluation — vLLM data-parallel.
+
+Inputs: OneThinker-eval style JSON/JSONL under `/.json`.
+Each record has:
+ data_type = "video"
+ problem_type = "tracking"
+ problem = "...question..."
+ path = "./.mp4"
+ solution/answer = '{"boxes":{"1":[x1,y1,x2,y2],"2":[...],...,"32":[...]}} '
+
+Schema notes (mirrors OneThinker's `eval_bench.py` 'tracking'):
+ - Answer is a JSON dict with key `boxes` only (NO `time` field).
+ - `boxes` is a dict keyed by integer second 1..32 (max 32 seconds).
+ - Each value is a 4-number bbox [x1, y1, x2, y2] in norm1000 coords.
+
+Metrics (GOT-10k standard, mirrors Table 7 of OneThinker / VideoChat-R1 papers):
+ AO — Average Overlap = mean per-frame IoU across ALL (sample, gt-frame)
+ pairs (missing pred frame counts as 0). Equivalent to taking
+ the per-sample mIoU and averaging — when every sample has the
+ same # of GT frames (got10k always = 32) — but reported as
+ FRAME-LEVEL mean.
+ R@0.3 — Success Rate @ 0.3 = fraction of (sample, gt-frame) pairs with
+ IoU >= 0.3.
+ R@0.5 — same, threshold 0.5.
+ R@0.7 — same, threshold 0.7.
+Extras (per-sample, useful when GT frame counts vary across samples):
+ mIoU — per-sample mean IoU averaged over samples
+ sample_R@{.3,.5,.7} — per-sample success rate (sample mIoU >= τ)
+ parse_rate — fraction of samples with a valid JSON answer
+
+Inference: client-side video decoding (verl-style)
+ - apply_chat_template → process_vision_info → multi_modal_data tensors
+ - Same engine setup as eval_stvg_vllm.py.
+
+Usage:
+ python eval_tracking_vllm.py \\
+ --model_path /path/to/ckpt \\
+ --bench_dir /path/to/OneThinker-eval \\
+ --datasets eval_got10k \\
+ --output_dir outputs/tracking/...
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+import time as _time
+from typing import Any, Dict, List, Optional
+
+os.environ.setdefault("VLLM_USE_V1", "1")
+os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
+os.environ.setdefault("FORCE_QWENVL_VIDEO_READER", "decord")
+os.environ.setdefault("DECORD_EOF_RETRY_MAX", "20480")
+
+
+# ---------------------------------------------------------------------------
+# Prompt — single source of truth in eval/task/eval_prompt.py
+# (matches data/joint/sft_joint_all.jsonl tracking samples).
+# ---------------------------------------------------------------------------
+
+_EVAL_TASK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _EVAL_TASK_DIR not in sys.path:
+ sys.path.insert(0, _EVAL_TASK_DIR)
+
+from canonical_data import load_json_records # noqa: E402
+from eval_prompt import ( # noqa: E402
+ GROUNDING_QUESTION_TEMPLATE_NO_THINK as QUESTION_TEMPLATE_NO_THINK,
+ TRACKING_TAIL,
+)
+
+
+# ---------------------------------------------------------------------------
+# Parsing helpers
+# ---------------------------------------------------------------------------
+
+_ANSWER_RE = re.compile(r"\s*(.*?)\s* ", re.DOTALL | re.IGNORECASE)
+
+
+def extract_answer(text: Optional[str]) -> str:
+ if not isinstance(text, str):
+ return ""
+ m = _ANSWER_RE.search(text)
+ return m.group(1).strip() if m else text.strip()
+
+
+def _load_json_relaxed(s: str) -> Optional[dict]:
+ """Try json.loads, then first balanced `{...}` substring."""
+ if not isinstance(s, str):
+ return None
+ s = s.strip()
+ if not s:
+ return None
+ try:
+ obj = json.loads(s)
+ return obj if isinstance(obj, dict) else None
+ except Exception:
+ pass
+ start = s.find("{")
+ if start < 0:
+ return None
+ depth = 0
+ for i in range(start, len(s)):
+ c = s[i]
+ if c == "{":
+ depth += 1
+ elif c == "}":
+ depth -= 1
+ if depth == 0:
+ try:
+ obj = json.loads(s[start:i + 1])
+ return obj if isinstance(obj, dict) else None
+ except Exception:
+ return None
+ return None
+
+
+def _is_list_of_numbers(x, n: Optional[int] = None) -> bool:
+ if not isinstance(x, list):
+ return False
+ if n is not None and len(x) != n:
+ return False
+ try:
+ for v in x:
+ float(v)
+ return True
+ except Exception:
+ return False
+
+
+# ---------------------------------------------------------------------------
+# IoU
+# ---------------------------------------------------------------------------
+
+def iou_2d(b1, b2) -> float:
+ if not _is_list_of_numbers(b1, 4) or not _is_list_of_numbers(b2, 4):
+ return 0.0
+ x1 = max(float(b1[0]), float(b2[0])); y1 = max(float(b1[1]), float(b2[1]))
+ x2 = min(float(b1[2]), float(b2[2])); y2 = min(float(b1[3]), float(b2[3]))
+ inter = max(0.0, x2 - x1) * max(0.0, y2 - y1)
+ a1 = max(0.0, b1[2] - b1[0]) * max(0.0, b1[3] - b1[1])
+ a2 = max(0.0, b2[2] - b2[0]) * max(0.0, b2[3] - b2[1])
+ u = a1 + a2 - inter
+ return inter / u if u > 1e-12 else 0.0
+
+
+def mean_iou_over_gt_frames(pred_boxes: Dict[str, List[float]],
+ gt_boxes: Dict[str, List[float]]) -> float:
+ """Mean IoU over EVERY gt frame; missing pred frames score 0.
+
+ This is the standard "tracking" metric used in OneThinker / LLaVA-ST
+ (eval_bench.py: tracking branch returns 'miou_gt' = this value)."""
+ if not isinstance(gt_boxes, dict) or not gt_boxes:
+ return 0.0
+ pred_dict = pred_boxes if isinstance(pred_boxes, dict) else {}
+ total, n = 0.0, 0
+ for k, gbox in gt_boxes.items():
+ total += iou_2d(pred_dict.get(k, []), gbox)
+ n += 1
+ return total / n if n > 0 else 0.0
+
+
+def per_frame_ious(pred_boxes: Dict[str, List[float]],
+ gt_boxes: Dict[str, List[float]]) -> List[float]:
+ """Return one IoU per GT frame (missing pred = 0).
+
+ Used for GOT-10k AO / R@τ which are FRAME-LEVEL metrics, NOT sample-level.
+ """
+ if not isinstance(gt_boxes, dict) or not gt_boxes:
+ return []
+ pred_dict = pred_boxes if isinstance(pred_boxes, dict) else {}
+ out: List[float] = []
+ for k, gbox in gt_boxes.items():
+ out.append(iou_2d(pred_dict.get(k, []), gbox))
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Box-dict normalization
+# ---------------------------------------------------------------------------
+
+def _normalize_boxes(boxes: Any) -> Dict[str, List[float]]:
+ """Coerce boxes into {str(int_sec): [x1,y1,x2,y2]}.
+
+ Tracking schema only allows dict (no list-of-list, since there's no time
+ span anchor). Bad entries are dropped.
+ """
+ if not isinstance(boxes, dict):
+ return {}
+ out: Dict[str, List[float]] = {}
+ for k, v in boxes.items():
+ try:
+ ki = int(float(k))
+ except Exception:
+ continue
+ if _is_list_of_numbers(v, 4):
+ out[str(ki)] = [float(x) for x in v]
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Dataset loading
+# ---------------------------------------------------------------------------
+
+def _read_json_or_jsonl(path: str) -> List[Dict[str, Any]]:
+ return load_json_records(path)
+
+
+_CANONICAL_DATASET_ALIASES = {
+ "eval_got10k": "got10k",
+}
+
+
+def load_dataset(bench_dir: str, name: str) -> List[Dict[str, Any]]:
+ base = name[:-5] if name.endswith(".json") else name
+ base = base[:-6] if base.endswith(".jsonl") else base
+ candidates = (base, _CANONICAL_DATASET_ALIASES.get(base, base))
+ for candidate in dict.fromkeys(candidates):
+ for ext in (".json", ".jsonl"):
+ p = os.path.join(bench_dir, candidate + ext)
+ if os.path.isfile(p):
+ return _read_json_or_jsonl(p)
+ raise FileNotFoundError(
+ f"Dataset not found for {name!r} under {bench_dir}"
+ )
+
+
+def _strip_leading_tags(text: str) -> str:
+ if not isinstance(text, str):
+ return ""
+ return re.sub(r"<(?:video|image)>\s*", "", text, count=1).strip()
+
+
+def build_prompt_text(example: Dict[str, Any], enable_thinking: bool = False,
+ prompt_mode: str = "default") -> str:
+ question = _strip_leading_tags(
+ example.get("problem") or example.get("question") or "")
+ return QUESTION_TEMPLATE_NO_THINK.format(Question=question) + TRACKING_TAIL
+
+
+def _resolve_video_path(rec: Dict[str, Any], base_prefix: str) -> Optional[str]:
+ """Resolve a video path with multi-tier fallback (same logic as STVG)."""
+ raw = (rec.get("path") or rec.get("video") or rec.get("video_path")
+ or rec.get("file_name"))
+ if not raw:
+ return None
+ if os.path.isabs(raw) and os.path.isfile(raw):
+ return raw
+ rel = raw.lstrip("./").lstrip("/")
+ base = (base_prefix or "").rstrip("/")
+ if not base:
+ return raw if os.path.isfile(raw) else None
+
+ candidates: List[str] = []
+ candidates.append(os.path.join(base, rel))
+ candidates.append(os.path.join(base, os.path.basename(rel)))
+
+ parts = rel.split("/")
+ for k in (1, 2):
+ if len(parts) > k:
+ candidates.append(os.path.join(base, *parts[k:]))
+
+ for cand in candidates:
+ if os.path.isfile(cand):
+ return cand
+
+ base_name = os.path.basename(rel)
+ if base_name:
+ for root, _dirs, files in os.walk(base):
+ if base_name in files:
+ return os.path.join(root, base_name)
+ depth = root[len(base):].count(os.sep)
+ if depth >= 3:
+ _dirs.clear()
+ return None
+
+
+def _extract_gt(rec: Dict[str, Any]) -> Dict[str, List[float]]:
+ blob = rec.get("solution") or rec.get("answer") or ""
+ ans = extract_answer(blob) if isinstance(blob, str) else ""
+ obj = _load_json_relaxed(ans)
+ if obj is None and isinstance(blob, dict):
+ obj = blob
+ if not isinstance(obj, dict):
+ return {}
+ return _normalize_boxes(obj.get("boxes"))
+
+
+def _extract_pred(text: str) -> Dict[str, List[float]]:
+ ans = extract_answer(text)
+ obj = _load_json_relaxed(ans)
+ if not isinstance(obj, dict):
+ return {}
+ return _normalize_boxes(obj.get("boxes"))
+
+
+# ---------------------------------------------------------------------------
+# vLLM packing — client-side video decoding (verl-style)
+# ---------------------------------------------------------------------------
+
+def _build_video_content(video_path: str, args,
+ video_start: Optional[float] = None,
+ video_end: Optional[float] = None) -> List[Dict[str, Any]]:
+ """Build the video content dict for the message.
+
+ `video_start` / `video_end` (in seconds) are optional time-window cuts
+ used by chunked re-prompt mode; pass None for the full video (default).
+
+ Env knobs:
+ EVAL_VIDEO_ITEM_ONETHINKER=1
+ Build the video item with ONLY {video, max_pixels, max_frames, fps},
+ omitting `min_pixels` and `total_pixels`. OneThinker's eval_bench.py
+ constructs the item this way; the extra keys can subtly shift the
+ per-frame pixel layout in qwen_vl_utils.
+ """
+ import os as _os
+ onethinker_style = _os.getenv("EVAL_VIDEO_ITEM_ONETHINKER", "0") == "1"
+ item: Dict[str, Any] = {
+ "type": "video",
+ "video": video_path,
+ "max_pixels": args.video_max_pixels,
+ "max_frames": args.max_frames,
+ "fps": args.fps,
+ }
+ if not onethinker_style:
+ item["min_pixels"] = args.video_min_pixels
+ item["total_pixels"] = args.video_total_pixels
+ if video_start is not None:
+ item["video_start"] = float(video_start)
+ if video_end is not None:
+ item["video_end"] = float(video_end)
+ return [item]
+
+
+# ---------------------------------------------------------------------------
+# Chunked re-prompt helpers (DIAGNOSTIC; only used when --chunked_reprompt > 0)
+# ---------------------------------------------------------------------------
+
+# Match an [x1,y1,x2,y2] bbox literal — the canonical pattern in GOT-10k
+# prompts: 'Given the bounding box [537,403,768,703] of the target object'.
+# We capture the FIRST 4-int bracket group in the prompt; tolerate spaces.
+_BBOX_4INT_RE = re.compile(
+ r"\[\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\]")
+
+
+def _replace_first_bbox_literal(text: str, new_box: List[float]) -> str:
+ """Replace the FIRST `[x1,y1,x2,y2]` integer-bbox literal in `text`
+ with `new_box`. Coords are integerised (round). Returns text unchanged
+ if no bbox literal is found (caller should warn)."""
+ if not _BBOX_4INT_RE.search(text):
+ return text
+ repl = "[{},{},{},{}]".format(*[int(round(float(v))) for v in new_box])
+ return _BBOX_4INT_RE.sub(repl, text, count=1)
+
+
+def _build_chunked_prompt_text(example: Dict[str, Any],
+ window_first_box: List[float],
+ window_first_sec: int,
+ window_last_sec: int,
+ enable_thinking: bool,
+ prompt_mode: str) -> str:
+ """For chunked re-prompt: build a prompt that asks for boxes in
+ [window_first_sec, window_last_sec] with `window_first_box` as anchor.
+
+ Uses the same template family as the default path; we substitute
+ (a) the first-frame bbox in the question,
+ (b) the "ONLY up to 32 seconds" temporal range hint,
+ (c) the example key sequence in TRACKING_TAIL (`"1", "2", "32"`)
+ with concrete keys from the window. Models tend to mimic the
+ example's key set rather than follow instructions, so (c) is
+ the most important substitution — without it, the model often
+ outputs `"1", "2", "32"` keys regardless of instruction.
+ """
+ base = build_prompt_text(example, enable_thinking=enable_thinking,
+ prompt_mode=prompt_mode)
+ # 1) replace first-frame bbox in question
+ base = _replace_first_bbox_literal(base, window_first_box)
+ # 2) tweak temporal range hint in the TRACKING_TAIL example.
+ # Original: 'ONLY up to 32 seconds'. Rewrite to the actual window.
+ base = re.sub(
+ r"ONLY up to \d+ seconds",
+ f"from second {window_first_sec} to second {window_last_sec} (inclusive)",
+ base, count=1,
+ )
+ base = re.sub(
+ r"correspond to a second \(1, 2, 3, \.\.\., \d+\)",
+ f"correspond to a second ({window_first_sec}, {window_first_sec+1}, "
+ f"..., {window_last_sec})",
+ base, count=1,
+ )
+ # 3) Critically: rewrite the example's key sequence to match the window.
+ # Original example uses keys "1", "2", "32"; in-context bias makes the
+ # model copy this exact key set. We swap to (first, first+1, last).
+ f, l = window_first_sec, window_last_sec
+ mid = f + 1 if l > f else f
+ base = re.sub(
+ r'\{"boxes": \{"1": \[[\d, ]+\], "2": \[[\d, ]+\], "32": \[[\d, ]+\]\}\}',
+ f'{{"boxes": {{"{f}": [405, 230, 654, 463], '
+ f'"{mid}": [435, 223, 678, 446], '
+ f'"{l}": [415, 203, 691, 487]}}}}',
+ base, count=1,
+ )
+ return base
+
+
+def _prepare_for_vllm(messages, processor, patch_size: int,
+ enable_thinking: bool = False):
+ """Prepare a vLLM-ready dict from a chat-style message list.
+
+ Env knobs (debugging / OneThinker reproduction):
+ EVAL_OMIT_ENABLE_THINKING_KW=1
+ Do NOT pass `enable_thinking=...` to apply_chat_template.
+ OneThinker's eval_bench.py omits this kwarg entirely; passing it
+ may inject a `\\n` generation prefix that mismatches the
+ model's expected format. Enable this when reproducing
+ OneThinker-8B paper numbers.
+ EVAL_OMIT_DO_RESIZE_OVERRIDE=1
+ Do NOT force `video_kwargs["do_resize"] = False`.
+ OneThinker's eval_bench.py leaves do_resize at its default;
+ forcing False can subtly change the per-frame pixel layout.
+ Enable this together with the above for full OneThinker parity.
+ """
+ from qwen_vl_utils import process_vision_info
+ import os as _os
+
+ omit_thinking_kw = _os.getenv("EVAL_OMIT_ENABLE_THINKING_KW", "0") == "1"
+ omit_do_resize = _os.getenv("EVAL_OMIT_DO_RESIZE_OVERRIDE", "0") == "1"
+
+ if omit_thinking_kw:
+ text = processor.apply_chat_template(
+ messages, tokenize=False, add_generation_prompt=True,
+ )
+ else:
+ text = processor.apply_chat_template(
+ messages, tokenize=False, add_generation_prompt=True,
+ enable_thinking=enable_thinking,
+ )
+
+ _images, video_inputs, video_kwargs = process_vision_info(
+ messages,
+ image_patch_size=patch_size,
+ return_video_kwargs=True,
+ return_video_metadata=True,
+ )
+
+ video_kwargs = video_kwargs or {}
+ if not omit_do_resize:
+ video_kwargs["do_resize"] = False
+
+ llm_input: Dict[str, Any] = {"prompt": text}
+ if video_inputs:
+ llm_input["multi_modal_data"] = {"video": video_inputs}
+ llm_input["mm_processor_kwargs"] = video_kwargs
+
+ return llm_input
+
+
+# ---------------------------------------------------------------------------
+# Per-dataset evaluation
+# ---------------------------------------------------------------------------
+
+def _evaluate_chunked_reprompt(records, resolved, llm, sampling_params,
+ processor, dataset_name, args):
+ """Diagnostic chunked re-prompt evaluation. Each video is split into
+ contiguous windows of `args.chunked_reprompt` seconds (over the 32 GT
+ seconds). Window 0 uses the prompt's original first-frame bbox; for
+ window k>=1 we substitute (a) the model's last predicted bbox of
+ window k-1 (or, if --reprompt_use_gt_first_box, the GT bbox at second
+ `window_first_sec`) and (b) only feed video frames within the window's
+ time range. Predicted boxes from each window are merged into a single
+ 32-key dict and fed to the standard metric.
+
+ Returns the same metrics dict as evaluate_one's no-reprompt path.
+ """
+ win = max(1, int(args.chunked_reprompt))
+ fps = float(args.fps or 2.0)
+ print(f"\n [CHUNKED RE-PROMPT] window={win}s, "
+ f"reprompt_use_gt_first_box={args.reprompt_use_gt_first_box}",
+ flush=True)
+ print(f" [WARNING] chunked re-prompt mode is EXPERIMENTAL. The temporal "
+ f"window (video_start/video_end) assumes GT 'second N' labels "
+ f"correspond to REAL seconds in the source video. Verify this on "
+ f"your dataset before trusting the numbers.", flush=True)
+
+ # ---- per-record loop (videos are not batched across chunks because
+ # different videos may be at different windows; we DO batch within a
+ # single window across videos) ----
+ mious: List[float] = []
+ all_frame_ious: List[float] = []
+ n_parsed = 0
+ per_sample: List[Dict[str, Any]] = []
+
+ n_videos = len(records)
+ # Number of windows for the canonical 32-second tracking task.
+ n_windows = (32 + win - 1) // win
+
+ # State across windows: each video's "current first-frame box" used to
+ # build the prompt of the next window, and the accumulated predictions.
+ cur_first_box: List[Optional[List[float]]] = []
+ accum_pred: List[Dict[str, List[float]]] = []
+ gt_per_video: List[Dict[str, List[float]]] = []
+
+ # Initialise from each record's first-frame GT bbox (which is what the
+ # original prompt already contains; we recover it via regex).
+ for rec in records:
+ gt_b = _extract_gt(rec)
+ gt_per_video.append(gt_b)
+ # Try to read first-frame box from prompt; fallback to gt[1].
+ prompt = (rec.get("problem") or rec.get("question") or "")
+ m = _BBOX_4INT_RE.search(prompt)
+ if m:
+ init = [float(x) for x in m.groups()]
+ else:
+ init = gt_b.get("1", []) or gt_b.get(min(gt_b.keys(), default="1"), [])
+ cur_first_box.append(init if _is_list_of_numbers(init, 4) else None)
+ accum_pred.append({})
+
+ t0 = _time.time()
+ bsz = args.batch_size
+
+ for w in range(n_windows):
+ win_first = w * win + 1
+ win_last = min((w + 1) * win, 32)
+ win_seconds = list(range(win_first, win_last + 1))
+ # IMPORTANT: GOT-10k GT-second labels (1, 2, ..., 32) are REAL seconds
+ # in the source video, NOT sampled-frame indices. So the temporal cut
+ # passed to qwen_vl_utils must be in real seconds too. Add a small
+ # cushion (+0.5s on each side) so the model sees the boundary frames
+ # cleanly even after FPS rounding.
+ v_start = max(0.0, float(win_first) - 1.0 - 0.5) # in seconds
+ v_end = float(win_last) + 0.5
+
+ print(f" [window {w+1}/{n_windows}] secs {win_first}..{win_last} "
+ f"(video {v_start:.2f}-{v_end:.2f}s)",
+ flush=True)
+
+ # Build inputs for ALL videos at this window
+ for start in range(0, n_videos, bsz):
+ batch_idx = list(range(start, min(start + bsz, n_videos)))
+ inputs_for_vllm = []
+ keep_idx: List[int] = []
+ for j in batch_idx:
+ rec, vp = records[j], resolved[j]
+ if not vp:
+ continue
+ # Decide first-box for this window
+ if w == 0:
+ fbox = cur_first_box[j]
+ elif args.reprompt_use_gt_first_box:
+ # Use GT box at win_first
+ fbox = gt_per_video[j].get(str(win_first))
+ if not _is_list_of_numbers(fbox, 4):
+ fbox = cur_first_box[j] # fallback
+ else:
+ fbox = cur_first_box[j]
+
+ if not _is_list_of_numbers(fbox, 4):
+ # No anchor: skip this window for this video
+ continue
+
+ # Build prompt for this window
+ prompt_text = _build_chunked_prompt_text(
+ rec, fbox, win_first, win_last,
+ enable_thinking=args.enable_thinking,
+ prompt_mode=args.prompt_mode)
+
+ content = _build_video_content(
+ vp, args, video_start=v_start, video_end=v_end)
+ content.append({"type": "text", "text": prompt_text})
+ messages = [{"role": "user", "content": content}]
+ try:
+ packed = _prepare_for_vllm(
+ messages, processor, args.patch_size,
+ enable_thinking=args.enable_thinking)
+ except Exception as e:
+ print(f" [warn] prepare failed "
+ f"(pid={rec.get('problem_id')}, w={w}): {e}",
+ flush=True)
+ continue
+ inputs_for_vllm.append(packed)
+ keep_idx.append(j)
+
+ if not inputs_for_vllm:
+ continue
+
+ try:
+ outs = llm.generate(inputs_for_vllm,
+ sampling_params=sampling_params)
+ except Exception as e:
+ print(f" [error] vLLM generate failed: {e}", flush=True)
+ continue
+
+ # Merge predictions into accum_pred and update cur_first_box
+ for j, out in zip(keep_idx, outs):
+ ans = out.outputs[0].text
+ pr = _extract_pred(ans)
+ # Filter to keys within this window
+ for sec in win_seconds:
+ box = pr.get(str(sec))
+ if _is_list_of_numbers(box, 4):
+ accum_pred[j][str(sec)] = [float(x) for x in box]
+ # Update cur_first_box for NEXT window using last predicted
+ # second of THIS window (preferred), else fall back.
+ last_box: Optional[List[float]] = None
+ for sec in reversed(win_seconds):
+ if str(sec) in accum_pred[j]:
+ last_box = accum_pred[j][str(sec)]
+ break
+ if last_box is not None:
+ cur_first_box[j] = last_box
+ # else: keep cur_first_box[j] unchanged
+
+ el = _time.time() - t0
+ print(f" [window {w+1}/{n_windows} done] elapsed={el:.1f}s",
+ flush=True)
+
+ # Compute metrics from accum_pred
+ for j, rec in enumerate(records):
+ gt_b = gt_per_video[j]
+ pr_b = accum_pred[j]
+ parsed = bool(pr_b)
+ if parsed:
+ n_parsed += 1
+ frame_ious = per_frame_ious(pr_b, gt_b) if gt_b else []
+ miou = (sum(frame_ious) / len(frame_ious)) if frame_ious else 0.0
+ mious.append(miou)
+ all_frame_ious.extend(frame_ious)
+ per_sample.append({
+ "problem_id": rec.get("problem_id"),
+ "path": rec.get("path"),
+ "gt_boxes_n": len(gt_b),
+ "pred_boxes_n": len(pr_b),
+ "miou": round(miou, 4),
+ "frame_ious": [round(v, 4) for v in frame_ious],
+ "answer": json.dumps({"boxes": pr_b}, ensure_ascii=False),
+ "_chunked": True,
+ })
+
+ n = len(mious)
+ nf = len(all_frame_ious)
+ metrics = {
+ "num_samples": n,
+ "num_frames": nf,
+ "AO": round(sum(all_frame_ious) / max(nf, 1) * 100, 2),
+ "R@0.3": round(sum(1 for v in all_frame_ious if v >= 0.3) / max(nf, 1) * 100, 2),
+ "R@0.5": round(sum(1 for v in all_frame_ious if v >= 0.5) / max(nf, 1) * 100, 2),
+ "R@0.7": round(sum(1 for v in all_frame_ious if v >= 0.7) / max(nf, 1) * 100, 2),
+ "mIoU": round(sum(mious) / max(n, 1) * 100, 2),
+ "sample_R@0.3": round(sum(1 for v in mious if v >= 0.3) / max(n, 1) * 100, 2),
+ "sample_R@0.5": round(sum(1 for v in mious if v >= 0.5) / max(n, 1) * 100, 2),
+ "sample_R@0.7": round(sum(1 for v in mious if v >= 0.7) / max(n, 1) * 100, 2),
+ "parse_rate": round(n_parsed / max(n, 1) * 100, 2),
+ "_chunked_reprompt": int(args.chunked_reprompt),
+ "_reprompt_use_gt_first_box": bool(args.reprompt_use_gt_first_box),
+ }
+
+ os.makedirs(args.output_dir, exist_ok=True)
+ suffix = f"_shard{args.index}" if args.chunk > 1 else ""
+ suffix += f"_chunked{args.chunked_reprompt}"
+ if args.reprompt_use_gt_first_box:
+ suffix += "_gtfirst"
+ out_path = os.path.join(
+ args.output_dir, f"results_{dataset_name}{suffix}.json")
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump(per_sample, f, ensure_ascii=False, indent=2)
+
+ print(f" [{dataset_name}] [CHUNKED w={win} "
+ f"gt_first={args.reprompt_use_gt_first_box}] "
+ f"AO={metrics['AO']:.2f}% R@0.3={metrics['R@0.3']:.2f}% "
+ f"R@0.5={metrics['R@0.5']:.2f}% R@0.7={metrics['R@0.7']:.2f}% "
+ f"parse={metrics['parse_rate']:.2f}% n={n} ({nf} frames)",
+ flush=True)
+ return metrics
+
+
+def evaluate_one(llm, sampling_params, processor,
+ dataset_name: str, args) -> Dict[str, Any]:
+ print(f"\n>>> Evaluating {dataset_name}", flush=True)
+ records = load_dataset(args.bench_dir, dataset_name)
+
+ records.sort(key=lambda r: (r.get("problem_id", 0),
+ str(r.get("path", ""))))
+
+ max_samples = int(getattr(args, "max_samples", 0) or 0)
+ if max_samples > 0:
+ records = records[:max_samples]
+
+ if args.chunk > 1:
+ records = records[args.index::args.chunk]
+ print(f" Shard {args.index}/{args.chunk}: {len(records)} samples",
+ flush=True)
+ else:
+ print(f" Loaded {len(records)} samples", flush=True)
+
+ # Filter to tracking only.
+ records = [
+ r for r in records
+ if str(r.get("problem_type", "")).strip().lower() == "tracking"
+ or not r.get("problem_type")
+ ]
+ if not records:
+ print(" [warn] no tracking records after filtering; skipping.",
+ flush=True)
+ return {}
+
+ # Pre-resolve all video paths.
+ print(f" resolving video paths under base_prefix={args.base_prefix} ...",
+ flush=True)
+ resolved: List[Optional[str]] = []
+ n_missing = 0
+ for rec in records:
+ vp = _resolve_video_path(rec, args.base_prefix)
+ resolved.append(vp)
+ if vp is None:
+ n_missing += 1
+ if n_missing:
+ examples = [r.get("path") for r, vp in zip(records, resolved)
+ if vp is None][:3]
+ print(f" [warn] {n_missing}/{len(records)} videos missing on disk. "
+ f"First few unresolved paths:", flush=True)
+ for ex in examples:
+ print(f" - {ex!r}", flush=True)
+ if n_missing == len(records):
+ raise SystemExit(
+ "[fatal] 0/{n} videos resolved. Set --base_prefix correctly. "
+ "Got base_prefix={base!r}, anno records reference paths like "
+ "{ex!r}".format(n=len(records), base=args.base_prefix,
+ ex=examples[0])
+ )
+ else:
+ print(f" all {len(records)} videos resolved OK.", flush=True)
+
+ # ----- Optional diagnostic path: chunked re-prompt -----
+ if int(getattr(args, "chunked_reprompt", 0) or 0) > 0:
+ return _evaluate_chunked_reprompt(
+ records, resolved, llm, sampling_params, processor,
+ dataset_name, args)
+
+ # Per-sample mIoU (kept for backwards compatibility / debugging)
+ mious: List[float] = []
+ # Frame-level IoU bag — used for GOT-10k AO / R@τ
+ all_frame_ious: List[float] = []
+ n_parsed = 0
+ per_sample = []
+
+ bsz = args.batch_size
+ t0 = _time.time()
+ for start in range(0, len(records), bsz):
+ batch = records[start:start + bsz]
+ batch_paths = resolved[start:start + bsz]
+ inputs_for_vllm = []
+ keep_idx: List[int] = []
+ for j, (rec, vp) in enumerate(zip(batch, batch_paths)):
+ if not vp:
+ continue
+ content = _build_video_content(vp, args)
+ content.append({"type": "text", "text": build_prompt_text(
+ rec, enable_thinking=args.enable_thinking,
+ prompt_mode=args.prompt_mode)})
+ messages = [{"role": "user", "content": content}]
+ try:
+ packed = _prepare_for_vllm(
+ messages, processor, args.patch_size,
+ enable_thinking=args.enable_thinking)
+ except Exception as e:
+ print(f" [warn] prepare failed (pid={rec.get('problem_id')}): "
+ f"{e}", flush=True)
+ continue
+ inputs_for_vllm.append(packed)
+ keep_idx.append(j)
+
+ texts = [""] * len(batch)
+ if inputs_for_vllm:
+ try:
+ outs = llm.generate(inputs_for_vllm,
+ sampling_params=sampling_params)
+ for j, out in zip(keep_idx, outs):
+ texts[j] = out.outputs[0].text
+ except Exception as e:
+ print(f" [error] vLLM generate failed @batch {start}: {e}",
+ flush=True)
+
+ for rec, answer in zip(batch, texts):
+ gt_b = _extract_gt(rec)
+ pr_b = _extract_pred(answer)
+ parsed = bool(pr_b)
+ if parsed:
+ n_parsed += 1
+ frame_ious = per_frame_ious(pr_b, gt_b) if gt_b else []
+ miou = (sum(frame_ious) / len(frame_ious)) if frame_ious else 0.0
+ mious.append(miou)
+ all_frame_ious.extend(frame_ious)
+
+ per_sample.append({
+ "problem_id": rec.get("problem_id"),
+ "path": rec.get("path"),
+ "gt_boxes_n": len(gt_b),
+ "pred_boxes_n": len(pr_b),
+ "miou": round(miou, 4),
+ "frame_ious": [round(v, 4) for v in frame_ious],
+ "answer": answer,
+ })
+
+ if (start // bsz) % 10 == 0:
+ done = start + len(batch)
+ el = _time.time() - t0
+ ao_cur = (sum(all_frame_ious) / len(all_frame_ious)
+ if all_frame_ious else 0.0)
+ print(f" [{done}/{len(records)}] {el:.1f}s "
+ f"AO={ao_cur:.4f} "
+ f"mIoU={sum(mious)/max(len(mious),1):.4f} "
+ f"parse={n_parsed}/{len(mious)}", flush=True)
+
+ n = len(mious)
+ nf = len(all_frame_ious)
+ metrics = {
+ "num_samples": n,
+ "num_frames": nf,
+ # ---- GOT-10k paper-standard (frame-level) ----
+ "AO": round(sum(all_frame_ious) / max(nf, 1) * 100, 2),
+ "R@0.3": round(sum(1 for v in all_frame_ious if v >= 0.3) / max(nf, 1) * 100, 2),
+ "R@0.5": round(sum(1 for v in all_frame_ious if v >= 0.5) / max(nf, 1) * 100, 2),
+ "R@0.7": round(sum(1 for v in all_frame_ious if v >= 0.7) / max(nf, 1) * 100, 2),
+ # ---- per-sample (legacy / OneThinker training-reward style) ----
+ "mIoU": round(sum(mious) / max(n, 1) * 100, 2),
+ "sample_R@0.3": round(sum(1 for v in mious if v >= 0.3) / max(n, 1) * 100, 2),
+ "sample_R@0.5": round(sum(1 for v in mious if v >= 0.5) / max(n, 1) * 100, 2),
+ "sample_R@0.7": round(sum(1 for v in mious if v >= 0.7) / max(n, 1) * 100, 2),
+ "parse_rate": round(n_parsed / max(n, 1) * 100, 2),
+ }
+
+ os.makedirs(args.output_dir, exist_ok=True)
+ suffix = f"_shard{args.index}" if args.chunk > 1 else ""
+ out_path = os.path.join(
+ args.output_dir, f"results_{dataset_name}{suffix}.json")
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump(per_sample, f, ensure_ascii=False, indent=2)
+
+ print(f" [{dataset_name}] "
+ f"AO={metrics['AO']:.2f}% R@0.3={metrics['R@0.3']:.2f}% "
+ f"R@0.5={metrics['R@0.5']:.2f}% R@0.7={metrics['R@0.7']:.2f}% "
+ f"mIoU={metrics['mIoU']:.2f}% "
+ f"parse={metrics['parse_rate']:.2f}% n={n} ({nf} frames)",
+ flush=True)
+ return metrics
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def parse_args():
+ p = argparse.ArgumentParser(
+ description="Tracking evaluation via vLLM (OneThinker prompt).")
+ p.add_argument("--model_path", required=True)
+ p.add_argument("--processor_path", default=None,
+ help="Defaults to --model_path.")
+ p.add_argument("--bench_dir", required=True,
+ help="Dir containing the tracking anno JSON / JSONL "
+ "(default name 'eval_got10k.json').")
+ p.add_argument("--datasets", default="eval_got10k",
+ help="Comma-separated names (with or without .json).")
+ p.add_argument("--max_samples", type=int, default=0,
+ help="Maximum samples before sharding; 0 evaluates all.")
+ p.add_argument("--output_dir", required=True)
+ p.add_argument("--base_prefix", default="",
+ help="Prefix for relative video paths inside records.")
+
+ # video sampling — client-side qwen_vl_utils (mirrors verl rollout).
+ # Defaults aligned with verl: min=4*32*32=4096, max=64*32*32=65536.
+ p.add_argument("--video_min_pixels", type=int, default=4 * 32 * 32)
+ p.add_argument("--video_max_pixels", type=int, default=64 * 32 * 32)
+ p.add_argument("--video_total_pixels", type=int,
+ default=256 * 64 * 32 * 32,
+ help="Total pixel budget across all sampled frames "
+ "(default: 256 frames * 64 tokens/frame * 32 * 32).")
+ # Tracking spec is at most 32 seconds @ 1 fps == 32 frames; but the
+ # real video may be longer/shorter — keep generous defaults like STVG
+ # so the model sees enough temporal context.
+ p.add_argument("--max_frames", type=int, default=64)
+ p.add_argument("--fps", type=int, default=2)
+
+ # vLLM engine
+ p.add_argument("--tensor_parallel_size", type=int, default=1)
+ p.add_argument("--gpu_memory_utilization", type=float, default=0.85)
+ p.add_argument("--max_model_len", type=int, default=32768)
+ p.add_argument("--max_new_tokens", type=int, default=1024)
+ p.add_argument("--max_num_batched_tokens", type=int, default=32768)
+ p.add_argument("--enforce_eager", action="store_true", default=False)
+ p.add_argument("--seed", type=int, default=42)
+ p.add_argument("--batch_size", type=int, default=64)
+ # thinking mode
+ p.add_argument("--enable_thinking", action="store_true", default=False,
+ help="Enable Qwen3 block. Default: disabled.")
+ p.add_argument("--prompt_mode", type=str, default="default",
+ choices=["default", "bare"],
+ help="'default': joint-SFT-aligned tracking template. "
+ "'bare': raw question only (for SFT ckpts trained on raw prompts).")
+ # sharding
+ p.add_argument("--chunk", type=int, default=1)
+ p.add_argument("--index", type=int, default=0)
+
+ # ----- DIAGNOSTIC: chunked re-prompt (test-time tracking refresh) -----
+ # Default 0 (disabled). When > 0, the video is split into windows of N
+ # GT-seconds; for window k>=1 we replace the "first-frame bbox" in the
+ # prompt with the LAST predicted bbox of window k-1, and feed only the
+ # frames for that window. Predictions are stitched into a 32-key dict
+ # and metrics are computed identically to the no-reprompt path.
+ # USE ONLY for diagnostics: this is NOT the default GOT-10k protocol.
+ p.add_argument("--chunked_reprompt", type=int, default=0,
+ help="Split each video into windows of N GT-seconds "
+ "and re-prompt the model with the previous "
+ "window's last predicted box. 0 = disabled "
+ "(default). Suggested values: 8 or 16.")
+ p.add_argument("--reprompt_use_gt_first_box", action="store_true",
+ default=False,
+ help="(diagnostic upper bound) When --chunked_reprompt>0, "
+ "use GT first-box of each window instead of the "
+ "model's previous prediction. This isolates 'visual "
+ "context refresh' benefit from 'good initial box' "
+ "benefit. Default: False.")
+ args = p.parse_args()
+ if args.max_samples < 0:
+ p.error("--max_samples must be non-negative")
+ return args
+
+
+def main():
+ args = parse_args()
+ if args.processor_path is None:
+ args.processor_path = args.model_path
+
+ os.makedirs(args.output_dir, exist_ok=True)
+
+ from vllm import LLM, SamplingParams
+ from transformers import AutoProcessor, AutoTokenizer
+
+ print(f"Model: {args.model_path}", flush=True)
+ print(f"Processor: {args.processor_path}", flush=True)
+ print(f"Bench dir: {args.bench_dir}", flush=True)
+ print(f"Datasets: {args.datasets}", flush=True)
+ print(f"Base prefix: {args.base_prefix}", flush=True)
+ print(f"Video: min_px={args.video_min_pixels} "
+ f"max_px={args.video_max_pixels} "
+ f"total_px={args.video_total_pixels} "
+ f"max_frames={args.max_frames} fps={args.fps}", flush=True)
+ print(f"Thinking: {args.enable_thinking}", flush=True)
+ print(f"Prompt mode: {args.prompt_mode}", flush=True)
+
+ processor = AutoProcessor.from_pretrained(
+ args.processor_path, padding_side="left",
+ do_resize=False, trust_remote_code=True)
+ tokenizer = AutoTokenizer.from_pretrained(
+ args.processor_path, trust_remote_code=True)
+ tokenizer.padding_side = "left"
+ processor.tokenizer = tokenizer
+
+ patch_size = processor.image_processor.patch_size
+ args.patch_size = patch_size
+ print(f"Patch size: {patch_size}", flush=True)
+
+ llm = LLM(
+ model=args.model_path,
+ tensor_parallel_size=args.tensor_parallel_size,
+ gpu_memory_utilization=args.gpu_memory_utilization,
+ max_model_len=args.max_model_len,
+ trust_remote_code=True,
+ dtype="bfloat16",
+ limit_mm_per_prompt={"video": 1, "image": 1},
+ enforce_eager=args.enforce_eager,
+ enable_chunked_prefill=True,
+ max_num_batched_tokens=args.max_num_batched_tokens,
+ seed=args.seed,
+ )
+ sampling_params = SamplingParams(
+ max_tokens=args.max_new_tokens,
+ temperature=0.0,
+ top_p=1.0,
+ stop_token_ids=[],
+ )
+
+ all_metrics: Dict[str, Dict[str, Any]] = {}
+ for ds in [s.strip() for s in args.datasets.split(",") if s.strip()]:
+ metrics = evaluate_one(llm, sampling_params, processor, ds, args)
+ if metrics:
+ all_metrics[ds] = metrics
+
+ suffix = f"_shard{args.index}" if args.chunk > 1 else ""
+ summary_path = os.path.join(args.output_dir, f"summary{suffix}.json")
+ with open(summary_path, "w", encoding="utf-8") as f:
+ json.dump(all_metrics, f, ensure_ascii=False, indent=2)
+ print(f"\nSummary -> {summary_path}", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/orarl.egg-info/PKG-INFO b/code/orarl.egg-info/PKG-INFO
new file mode 100644
index 0000000000000000000000000000000000000000..46b3ccb3ed12bfa6b4f1556877e844f0254836b1
--- /dev/null
+++ b/code/orarl.egg-info/PKG-INFO
@@ -0,0 +1,230 @@
+Metadata-Version: 2.4
+Name: orarl
+Version: 0.1.0
+Summary: Annotations as rollouts for unified video MLLM reinforcement learning
+Author: Yunheng Li, Guohong Mu, Hao Li, Shengsheng Qian, Dingwen Zhang, Qibin Hou, Ming-Ming Cheng
+License-Expression: Apache-2.0
+Project-URL: Homepage, https://orarl.github.io/
+Project-URL: Documentation, https://orarl.github.io/
+Keywords: reinforcement-learning,video-understanding,multimodal,grpo
+Classifier: Development Status :: 3 - Alpha
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
+Requires-Python: >=3.10
+Description-Content-Type: text/markdown
+License-File: LICENSE
+License-File: NOTICE
+Requires-Dist: numpy
+Requires-Dist: PyYAML
+Requires-Dist: torch
+Provides-Extra: test
+Requires-Dist: pytest; extra == "test"
+Provides-Extra: lint
+Requires-Dist: ruff; extra == "lint"
+Provides-Extra: hf
+Requires-Dist: huggingface_hub; extra == "hf"
+Dynamic: license-file
+
+简体中文
+
+
+
+## Why OraRL
+
+- **Annotation-as-rollout:** annotations become reliable positive rollouts while
+ policy samples retain an on-policy baseline.
+- **Seven task families:** one update rule covers temporal and spatial grounding,
+ segmentation, tracking, spatial-temporal grounding, video QA, and spatial
+ intelligence.
+- **Efficient training (4B):** sign-balanced pruning delivers **1.48× faster
+ updates** (**92.5 → 62.4 s/step**) while reducing peak per-GPU memory from
+ **62.4 to 50.9 GB**.
+- **Efficient inference:** on one H20 with vLLM in BF16, weight loading occupies
+ **8.6 GiB (4B)** and **17.6 GiB (9B)**. On ten-minute, 2-fps videos,
+ answer-only decoding cuts median post-TTFT latency from **4.78 s to 130 ms**
+ and total latency from **29.03 to 24.30 s**.
+- **Multimodal veRL infrastructure:** a unified video contract carries cached
+ artifacts, raw paths, or inline frame tensors through vLLM rollouts and FSDP
+ updates, with decode-once frame reuse, temporal metadata, task-grouped
+ batching, asynchronous Ray rewards, and safe hybrid-engine cache handling.
+
+## OraRL in One Update
+
+
+
+
+
+An OraRL update separates reliable annotation guidance from on-policy
+normalization:
+
+1. **Build the group:** append one serialized annotation rollout to the policy
+ samples generated for the same prompt.
+2. **Keep the baseline on-policy:** estimate the group baseline from policy
+ rewards only.
+3. **Guide and select:** convert the annotation-policy reward gap into a
+ correction, then retain a sign-balanced subset for the update.
+
+This design uses task-native annotations directly and requires no
+chain-of-thought supervision or decoding.
+
+## Video-ORA Results
+
+
+
+
+
+### Dataset-Level Results
+
+
+
+
+
+
+
+Video-ORA-9B leads the matched seven-family comparison without CoT decoding.
+Best and second-best values are highlighted per row; `†` denotes an
+original-report value whose frame, prompt, split, or decoding settings may
+differ. Averages require complete family coverage.
+
+
+
+### Model Scaling
+
+
+
+
+
+### Data Scaling
+
+
+
+
+
+## Models
+
+| Model | Backbone | Released recipe | Weights |
+| --- | --- | --- | --- |
+| **Video-ORA-9B** | Qwen3.5-9B | `orarl_9b.yaml` | [Hugging Face](https://huggingface.co/OraRL/Video-ORA-9B) |
+| **Video-ORA-4B** | Qwen3.5-4B | `orarl_4b.yaml` | Hugging Face (coming soon) |
+
+### vLLM Serving
+
+Both Video-ORA checkpoints load directly with **vLLM 0.19.1** for
+OpenAI-compatible serving:
+
+```bash
+MODEL=OraRL/Video-ORA-9B
+
+vllm serve "$MODEL" \
+ --served-model-name Video-ORA-9B \
+ --trust-remote-code \
+ --dtype bfloat16 \
+ --tensor-parallel-size 1 \
+ --max-model-len 131072 \
+ --limit-mm-per-prompt '{"image": 1, "video": 1}'
+```
+
+Set `--tensor-parallel-size` to the GPU count for multi-GPU deployment and
+lower `--max-model-len` on smaller-memory devices. Use
+`enable_thinking=false` in the chat template for answer-only inference.
+
+## Use OraRL
+
+The release is organized around three user-facing workflows:
+
+1. **[Environment](docs/environment.md):** install the pinned CUDA stack that
+ covers both the bundled trainer and the evaluators.
+2. **[Training](docs/training.md):** prepare licensed local training data and
+ launch GRPO or OraRL on one or multiple nodes.
+3. **[Evaluation](docs/evaluation.md):** download Video-ORA and OraRL-Data,
+ then run a smoke test or the complete paper suite.
+
+Training and evaluation are dry runs by default; inspect the resolved command
+before adding `--run`. Checkpoints and evaluation media are hosted under the
+[OraRL Hugging Face organization](https://huggingface.co/OraRL).
+
+## Acknowledgements
+
+OraRL is built on [veRL](https://github.com/volcengine/verl) — a
+high-performance RL framework with HybridEngine. We thank its authors and
+contributors for open-sourcing the training infrastructure.
+
+## License
+
+OraRL source is released under [Apache-2.0](LICENSE). Datasets, models,
+benchmarks, and optional dependencies retain their original licenses; see
+[NOTICE](NOTICE).
+
+## Citation
+
+If you find OraRL useful, please consider giving this repository a ⭐ and
+citing our [paper](https://arxiv.org/abs/2608.20492).
+
+```bibtex
+@article{li2026orarl,
+ title = {Annotations as Rollouts: Efficient and Scalable
+ Reinforcement Learning for Video MLLMs},
+ author = {Li, Yunheng and Mu, Guohong and Li, Hao and
+ Qian, Shengsheng and Zhang, Dingwen and Hou, Qibin
+ and Cheng, Ming-Ming},
+ journal = {arXiv preprint arXiv:2608.20492},
+ year = {2026},
+ url = {https://arxiv.org/abs/2608.20492}
+}
+```
diff --git a/code/orarl.egg-info/SOURCES.txt b/code/orarl.egg-info/SOURCES.txt
new file mode 100644
index 0000000000000000000000000000000000000000..109aa7dc0e190f0ad7152e1ed4dc70e643a673d0
--- /dev/null
+++ b/code/orarl.egg-info/SOURCES.txt
@@ -0,0 +1,196 @@
+LICENSE
+MANIFEST.in
+NOTICE
+README.md
+environment.yml
+pyproject.toml
+requirements-cu129.txt
+configs/data_sources.example.yaml
+configs/grpo_4b.yaml
+configs/grpo_9b.yaml
+configs/orarl_4b.yaml
+configs/orarl_9b.yaml
+data/eval/README.md
+data/eval/datasets.jsonl
+docs/environment.md
+docs/environment_zh.md
+docs/evaluation.md
+docs/evaluation_zh.md
+docs/training.md
+docs/training_zh.md
+eval/README.md
+eval/task/canonical_data.py
+eval/task/eval.sh
+eval/task/eval_image_mc_vllm.py
+eval/task/eval_prompt.py
+eval/task/eval_revsi_multinode.sh
+eval/task/eval_vllm.py
+eval/task/qwenvl_decord_patch.py
+eval/task/mindcube/data_utils.py
+eval/task/mmsi/eval_mmsi_transformers.py
+eval/task/mmsi/run_eval_transformers.sh
+eval/task/revsi/eval_revsi_vllm.py
+eval/task/revsi/merge_multinode_shards.py
+eval/task/revsi/run_eval_vllm.sh
+eval/task/segmentation/eval_seg_vllm.py
+eval/task/segmentation/post_sam2.py
+eval/task/segmentation/run_eval_vllm.sh
+eval/task/spatial_grounding/_grounding_utils.py
+eval/task/spatial_grounding/eval_refcoco_vllm.py
+eval/task/spatial_temporal_grounding/eval_stvg_vllm.py
+eval/task/temporal_grounding/eval_timelens_hf.py
+eval/task/temporal_grounding/run_eval.sh
+eval/task/tracking/eval_tracking_vllm.py
+orarl/__init__.py
+orarl/resources.py
+orarl.egg-info/PKG-INFO
+orarl.egg-info/SOURCES.txt
+orarl.egg-info/dependency_links.txt
+orarl.egg-info/entry_points.txt
+orarl.egg-info/requires.txt
+orarl.egg-info/top_level.txt
+orarl/algorithm/__init__.py
+orarl/algorithm/_utils.py
+orarl/algorithm/advantages.py
+orarl/algorithm/config.py
+orarl/algorithm/correction.py
+orarl/algorithm/selection.py
+orarl/cli/__init__.py
+orarl/cli/eval_data.py
+orarl/cli/evaluate.py
+orarl/cli/prepare.py
+orarl/cli/train.py
+orarl/data/__init__.py
+orarl/data/build.py
+orarl/data/identity.py
+orarl/data/schema.py
+orarl/evaluation/__init__.py
+orarl/evaluation/card.py
+orarl/evaluation/converters.py
+orarl/evaluation/hub.py
+orarl/evaluation/layout.py
+orarl/evaluation/manifest.py
+orarl/evaluation/schema.py
+orarl/evaluation/sources.py
+orarl/evaluation/staging.py
+orarl/rewards/__init__.py
+orarl/rewards/router.py
+orarl/rewards/types.py
+orarl/rewards/adapters/__init__.py
+orarl/rewards/adapters/_common.py
+orarl/rewards/adapters/segmentation.py
+orarl/rewards/adapters/spatial_grounding.py
+orarl/rewards/adapters/spatial_intelligence.py
+orarl/rewards/adapters/spatial_temporal_grounding.py
+orarl/rewards/adapters/temporal_grounding.py
+orarl/rewards/adapters/tracking.py
+orarl/rewards/adapters/video_qa.py
+scripts/check_environment.py
+scripts/check_release.py
+scripts/create_conda_env.sh
+scripts/create_eval_source_manifest.py
+scripts/evaluate.sh
+scripts/finalize_eval_index.py
+scripts/install_conda_runtime_hook.sh
+scripts/launch_multinode.sh
+scripts/model_merger.py
+scripts/organize_evaluation_outputs.py
+scripts/prepare_data.sh
+scripts/smoke_evaluation.sh
+scripts/smoke_training.sh
+scripts/train_grpo.sh
+scripts/train_orarl.sh
+tests/test_algorithm_advantages.py
+tests/test_algorithm_correction.py
+tests/test_algorithm_selection.py
+tests/test_builtin_reward_adapters.py
+tests/test_cli_evaluate.py
+tests/test_cli_metadata.py
+tests/test_cli_train.py
+tests/test_data_build.py
+tests/test_data_schema.py
+tests/test_environment_spec.py
+tests/test_eval_canonical_data.py
+tests/test_eval_source_discovery.py
+tests/test_evaluation_manifest.py
+tests/test_evaluation_release.py
+tests/test_evaluation_runtime.py
+tests/test_evaluation_schema.py
+tests/test_evaluation_staging.py
+tests/test_organize_evaluation_outputs.py
+tests/test_release_hygiene.py
+tests/test_resources.py
+tests/test_rewards.py
+tests/test_runtime_reward_entrypoint.py
+tests/test_training_jsonl_dataset.py
+tests/test_training_runtime.py
+verl/__init__.py
+verl/protocol.py
+verl/models/__init__.py
+verl/models/monkey_patch.py
+verl/models/transformers/__init__.py
+verl/models/transformers/flash_attention_utils.py
+verl/models/transformers/qwen2_vl.py
+verl/models/transformers/qwen3_5.py
+verl/models/transformers/qwen3_vl.py
+verl/single_controller/__init__.py
+verl/single_controller/base/__init__.py
+verl/single_controller/base/decorator.py
+verl/single_controller/base/worker.py
+verl/single_controller/base/worker_group.py
+verl/single_controller/base/register_center/__init__.py
+verl/single_controller/base/register_center/ray.py
+verl/single_controller/ray/__init__.py
+verl/single_controller/ray/base.py
+verl/trainer/__init__.py
+verl/trainer/config.py
+verl/trainer/core_algos.py
+verl/trainer/data_loader.py
+verl/trainer/main.py
+verl/trainer/metrics.py
+verl/trainer/orarl_config.py
+verl/trainer/orarl_post_selection.py
+verl/trainer/orarl_selection.py
+verl/trainer/ray_trainer.py
+verl/utils/__init__.py
+verl/utils/dataset.py
+verl/utils/flops_counter.py
+verl/utils/fsdp_utils.py
+verl/utils/model_utils.py
+verl/utils/multimodal_contract.py
+verl/utils/prompt_template.py
+verl/utils/py_functional.py
+verl/utils/seqlen_balancing.py
+verl/utils/tokenizer.py
+verl/utils/torch_dtypes.py
+verl/utils/torch_functional.py
+verl/utils/ulysses.py
+verl/utils/checkpoint/__init__.py
+verl/utils/checkpoint/checkpoint_manager.py
+verl/utils/checkpoint/fsdp_checkpoint_manager.py
+verl/utils/logger/__init__.py
+verl/utils/logger/gen_logger.py
+verl/utils/logger/logger.py
+verl/workers/__init__.py
+verl/workers/config.py
+verl/workers/fsdp_workers.py
+verl/workers/actor/__init__.py
+verl/workers/actor/base.py
+verl/workers/actor/config.py
+verl/workers/actor/dp_actor.py
+verl/workers/critic/__init__.py
+verl/workers/critic/base.py
+verl/workers/critic/config.py
+verl/workers/critic/dp_critic.py
+verl/workers/reward/__init__.py
+verl/workers/reward/config.py
+verl/workers/reward/function.py
+verl/workers/rollout/__init__.py
+verl/workers/rollout/base.py
+verl/workers/rollout/config.py
+verl/workers/rollout/hf_rollout.py
+verl/workers/rollout/vllm_rollout_spmd.py
+verl/workers/sharding_manager/__init__.py
+verl/workers/sharding_manager/base.py
+verl/workers/sharding_manager/fsdp_ulysses.py
+verl/workers/sharding_manager/fsdp_vllm.py
\ No newline at end of file
diff --git a/code/orarl.egg-info/dependency_links.txt b/code/orarl.egg-info/dependency_links.txt
new file mode 100644
index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc
--- /dev/null
+++ b/code/orarl.egg-info/dependency_links.txt
@@ -0,0 +1 @@
+
diff --git a/code/orarl.egg-info/entry_points.txt b/code/orarl.egg-info/entry_points.txt
new file mode 100644
index 0000000000000000000000000000000000000000..709973346314612295b0da8478f38837c2896cfc
--- /dev/null
+++ b/code/orarl.egg-info/entry_points.txt
@@ -0,0 +1,5 @@
+[console_scripts]
+orarl-eval = orarl.cli.evaluate:main
+orarl-eval-data = orarl.cli.eval_data:main
+orarl-prepare = orarl.cli.prepare:main
+orarl-train = orarl.cli.train:main
diff --git a/code/orarl.egg-info/requires.txt b/code/orarl.egg-info/requires.txt
new file mode 100644
index 0000000000000000000000000000000000000000..3a476f18c1915282ed840b17c37848c6a66ca26d
--- /dev/null
+++ b/code/orarl.egg-info/requires.txt
@@ -0,0 +1,12 @@
+numpy
+PyYAML
+torch
+
+[hf]
+huggingface_hub
+
+[lint]
+ruff
+
+[test]
+pytest
diff --git a/code/orarl.egg-info/top_level.txt b/code/orarl.egg-info/top_level.txt
new file mode 100644
index 0000000000000000000000000000000000000000..515595152e429980809f3de1df69091ae10204fe
--- /dev/null
+++ b/code/orarl.egg-info/top_level.txt
@@ -0,0 +1,2 @@
+orarl
+verl
diff --git a/code/orarl/__init__.py b/code/orarl/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a7ab8ad7377f8ebf105427f376753041cd6cc72d
--- /dev/null
+++ b/code/orarl/__init__.py
@@ -0,0 +1,19 @@
+"""OraRL: oracle-augmented reinforcement learning for multimodal reasoning."""
+
+from __future__ import annotations
+
+from .resources import (
+ available_configs,
+ config_path,
+ documentation_path,
+ script_path,
+)
+
+__version__ = "0.1.0"
+__all__ = [
+ "__version__",
+ "available_configs",
+ "config_path",
+ "documentation_path",
+ "script_path",
+]
diff --git a/code/orarl/algorithm/__init__.py b/code/orarl/algorithm/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..706711cfc60678447b11aa5626994933fe13fecc
--- /dev/null
+++ b/code/orarl/algorithm/__init__.py
@@ -0,0 +1,84 @@
+# Copyright 2026 The OraRL Authors
+#
+# 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.
+"""Public, dependency-light OraRL algorithm surface."""
+
+from .advantages import (
+ DIRECTIONAL_GAIN_MAX,
+ DIRECTIONAL_GAIN_MIN,
+ apply_detached_oracle_advantage,
+ apply_grpo_advantages,
+ apply_orarl_advantages,
+ compute_directional_gain,
+ compute_grpo_advantages,
+ compute_grpo_outcome_advantage,
+ compute_orarl_advantages,
+ compute_orarl_policy_advantages,
+ detached_oracle_advantage,
+ directional_gain,
+ normalized_reward_gap,
+ raw_center_on_policy,
+ transform_directional_utility,
+ vanilla_grpo_advantage,
+)
+from .config import (
+ CorrectionConfig,
+ DetachedOracleConfig,
+ DirectionalGainConfig,
+ GRPOConfig,
+ OraRLConfig,
+ PostSelectionReference,
+ SelectionConfig,
+)
+from .correction import (
+ apply_post_selection_advantage_correction,
+ apply_post_selection_correction,
+ balance_post_selection_group,
+ build_pre_selection_references,
+ capture_pre_selection_references,
+ correct_post_selection_group,
+)
+from .selection import select_sign_balanced_rollouts
+
+__all__ = [
+ "DIRECTIONAL_GAIN_MAX",
+ "DIRECTIONAL_GAIN_MIN",
+ "CorrectionConfig",
+ "DetachedOracleConfig",
+ "DirectionalGainConfig",
+ "GRPOConfig",
+ "OraRLConfig",
+ "PostSelectionReference",
+ "SelectionConfig",
+ "apply_detached_oracle_advantage",
+ "apply_grpo_advantages",
+ "apply_orarl_advantages",
+ "apply_post_selection_advantage_correction",
+ "apply_post_selection_correction",
+ "balance_post_selection_group",
+ "build_pre_selection_references",
+ "capture_pre_selection_references",
+ "compute_directional_gain",
+ "compute_grpo_advantages",
+ "compute_grpo_outcome_advantage",
+ "compute_orarl_advantages",
+ "compute_orarl_policy_advantages",
+ "correct_post_selection_group",
+ "detached_oracle_advantage",
+ "directional_gain",
+ "normalized_reward_gap",
+ "raw_center_on_policy",
+ "select_sign_balanced_rollouts",
+ "transform_directional_utility",
+ "vanilla_grpo_advantage",
+]
diff --git a/code/orarl/algorithm/_utils.py b/code/orarl/algorithm/_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4b2c3c4560db83670d7785a4567aee6fd6777da
--- /dev/null
+++ b/code/orarl/algorithm/_utils.py
@@ -0,0 +1,180 @@
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Copyright 2026 The OraRL Authors
+#
+# 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.
+"""Shared tensor and duck-typed batch utilities.
+
+Parts of the masking and grouping behavior are derived from the Apache-2.0
+EasyR1/verl implementation.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from collections.abc import Hashable, Sequence
+from typing import Any
+
+import numpy as np
+import torch
+
+
+def group_rows(
+ group_ids: Sequence[Any] | np.ndarray | torch.Tensor,
+ size: int,
+) -> dict[Any, list[int]]:
+ """Return row indices grouped in first-seen order."""
+
+ if isinstance(group_ids, torch.Tensor):
+ if group_ids.ndim != 1:
+ raise ValueError("group_ids must be one-dimensional.")
+ values = group_ids.detach().cpu().tolist()
+ else:
+ array = np.asarray(group_ids, dtype=object)
+ if array.ndim != 1:
+ raise ValueError("group_ids must be one-dimensional.")
+ values = array.tolist()
+ if len(values) != size:
+ raise ValueError(f"group_ids has {len(values)} rows, expected {size}.")
+
+ grouped: dict[Any, list[int]] = defaultdict(list)
+ for row, value in enumerate(values):
+ if isinstance(value, np.generic):
+ value = value.item()
+ if not isinstance(value, Hashable):
+ raise TypeError(f"group id at row {row} is not hashable.")
+ grouped[value].append(row)
+ return dict(grouped)
+
+
+def boolean_mask(
+ values: Sequence[bool] | np.ndarray | torch.Tensor,
+ size: int,
+ *,
+ device: torch.device,
+ name: str = "mask",
+) -> torch.Tensor:
+ """Materialize a one-dimensional boolean mask on ``device``."""
+
+ if isinstance(values, torch.Tensor):
+ result = values.detach().to(device=device, dtype=torch.bool)
+ else:
+ array = np.asarray(values, dtype=bool)
+ result = torch.as_tensor(array, dtype=torch.bool, device=device)
+ if result.ndim != 1 or result.numel() != size:
+ raise ValueError(f"{name} must be one-dimensional with {size} rows.")
+ return result
+
+
+def validate_floating_tensor(name: str, value: torch.Tensor) -> None:
+ if not isinstance(value, torch.Tensor):
+ raise TypeError(f"{name} must be a torch.Tensor.")
+ if not value.is_floating_point():
+ raise TypeError(f"{name} must have a floating dtype.")
+ if not bool(torch.isfinite(value).all()):
+ raise ValueError(f"{name} contains non-finite values.")
+
+
+def validate_response_mask(response_mask: torch.Tensor) -> None:
+ """Accept boolean, integer, or floating response masks."""
+
+ if not isinstance(response_mask, torch.Tensor):
+ raise TypeError("response_mask must be a torch.Tensor.")
+ if response_mask.is_complex():
+ raise TypeError("response_mask must have a real-valued dtype.")
+ if not bool(torch.isfinite(response_mask).all()):
+ raise ValueError("response_mask contains non-finite values.")
+
+
+def sequence_rewards(
+ rewards: torch.Tensor,
+ response_mask: torch.Tensor | None,
+) -> tuple[torch.Tensor, bool]:
+ """Collapse token rewards, returning ``(scores, token_input)``."""
+
+ validate_floating_tensor("rewards", rewards)
+ if rewards.ndim == 1:
+ if response_mask is not None:
+ raise ValueError("response_mask is only valid with token-level rewards.")
+ return rewards.detach(), False
+ if rewards.ndim != 2:
+ raise ValueError("rewards must be one- or two-dimensional.")
+ if response_mask is None or response_mask.shape != rewards.shape:
+ raise ValueError("token-level rewards require a matching response_mask.")
+ validate_response_mask(response_mask)
+ mask = response_mask.detach().to(dtype=rewards.dtype)
+ return (rewards.detach() * mask).sum(dim=-1), True
+
+
+def sequence_advantages(
+ advantages: torch.Tensor,
+ response_mask: torch.Tensor,
+) -> torch.Tensor:
+ """Collapse token advantages with a response-length-neutral masked mean."""
+
+ validate_floating_tensor("advantages", advantages)
+ validate_response_mask(response_mask)
+ if advantages.ndim != 2 or advantages.shape != response_mask.shape:
+ raise ValueError("advantages and response_mask must be matching matrices.")
+ mask = response_mask.detach().to(dtype=advantages.dtype)
+ lengths = mask.sum(dim=-1)
+ if bool((lengths <= 0).any()):
+ raise ValueError("every row must contain at least one valid response token.")
+ return (advantages.detach() * mask).sum(dim=-1) / lengths
+
+
+def broadcast_sequence_values(
+ values: torch.Tensor,
+ response_mask: torch.Tensor,
+ *,
+ dtype: torch.dtype | None = None,
+) -> torch.Tensor:
+ """Broadcast one scalar per row over valid response tokens."""
+
+ if values.ndim != 1 or values.shape[0] != response_mask.shape[0]:
+ raise ValueError("values must contain one scalar per response-mask row.")
+ target_dtype = response_mask.dtype if dtype is None else dtype
+ return values.to(dtype=target_dtype).unsqueeze(-1) * response_mask.to(dtype=target_dtype)
+
+
+def sample_std(values: torch.Tensor) -> torch.Tensor:
+ """Sample standard deviation, with zero for fewer than two rows."""
+
+ if values.numel() < 2:
+ return values.new_zeros(())
+ return values.std(unbiased=True)
+
+
+def batch_reward_tokens(data: Any, preferred_key: str | None = None) -> torch.Tensor:
+ """Read token rewards from a DataProto-like object."""
+
+ if preferred_key is not None:
+ if preferred_key not in data.batch:
+ raise ValueError(f"batch is missing reward key {preferred_key!r}.")
+ return data.batch[preferred_key]
+ for key in ("token_level_scores", "token_level_rewards"):
+ if key in data.batch:
+ return data.batch[key]
+ raise ValueError("batch requires token_level_scores or token_level_rewards.")
+
+
+def copy_and_refresh_meta(data: Any) -> None:
+ """Detach selected metadata and refresh token counts when available."""
+
+ if not hasattr(data, "meta_info"):
+ return
+ meta = getattr(data, "meta_info")
+ data.meta_info = dict(meta) if meta is not None else {}
+ if hasattr(data, "batch") and "attention_mask" in data.batch:
+ data.meta_info["global_token_num"] = (
+ data.batch["attention_mask"].sum(dim=-1).detach().cpu().tolist()
+ )
diff --git a/code/orarl/algorithm/advantages.py b/code/orarl/algorithm/advantages.py
new file mode 100644
index 0000000000000000000000000000000000000000..299b4a2ecbabcf0f9ada52e8d467d6ac3c792503
--- /dev/null
+++ b/code/orarl/algorithm/advantages.py
@@ -0,0 +1,598 @@
+# Copyright 2022 The HuggingFace Team
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Copyright 2026 The OraRL Authors
+#
+# 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.
+"""Pure advantage construction for vanilla GRPO and OraRL.
+
+The grouped baseline and token broadcasting behavior are derived from the
+Apache-2.0 EasyR1/verl implementation. OraRL keeps the annotation outside the
+policy baseline and treats its advantage as a detached scalar.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any
+
+import numpy as np
+import torch
+
+from ._utils import (
+ batch_reward_tokens,
+ boolean_mask,
+ broadcast_sequence_values,
+ group_rows,
+ sample_std,
+ sequence_advantages,
+ sequence_rewards,
+ validate_floating_tensor,
+)
+from .config import (
+ DetachedOracleConfig,
+ DirectionalGainConfig,
+ GRPOConfig,
+ OraRLConfig,
+)
+
+DIRECTIONAL_GAIN_MIN = 1.0
+DIRECTIONAL_GAIN_MAX = 4.0
+
+
+@torch.no_grad()
+def vanilla_grpo_advantage(
+ rewards: torch.Tensor,
+ *,
+ config: GRPOConfig | None = None,
+) -> torch.Tensor:
+ """Compute vanilla GRPO advantage for one rollout group."""
+
+ cfg = GRPOConfig() if config is None else config
+ validate_floating_tensor("rewards", rewards)
+ if rewards.ndim != 1:
+ raise ValueError("rewards must be one-dimensional.")
+ if rewards.numel() < 2:
+ raise ValueError("vanilla GRPO requires at least two rollouts per group.")
+ centered = rewards.detach() - rewards.detach().mean()
+ if cfg.normalize:
+ centered = centered / (sample_std(rewards.detach()) + cfg.eps)
+ return centered
+
+
+@torch.no_grad()
+def compute_grpo_advantages(
+ rewards: torch.Tensor,
+ group_ids: Sequence[Any] | np.ndarray | torch.Tensor,
+ response_mask: torch.Tensor | None = None,
+ *,
+ config: GRPOConfig | None = None,
+) -> torch.Tensor:
+ """Compute grouped vanilla GRPO advantages.
+
+ One-dimensional input produces one scalar per row. Token-level input
+ produces a matrix with each group scalar broadcast over valid tokens.
+ """
+
+ cfg = GRPOConfig() if config is None else config
+ scores, token_input = sequence_rewards(rewards, response_mask)
+ grouped = group_rows(group_ids, scores.numel())
+ output = torch.empty_like(scores)
+ for rows in grouped.values():
+ row_index = torch.tensor(rows, dtype=torch.long, device=scores.device)
+ output.index_copy_(
+ 0,
+ row_index,
+ vanilla_grpo_advantage(scores.index_select(0, row_index), config=cfg),
+ )
+ if not token_input:
+ return output
+ assert response_mask is not None
+ return broadcast_sequence_values(output, response_mask, dtype=rewards.dtype)
+
+
+@torch.no_grad()
+def compute_grpo_outcome_advantage(
+ token_level_rewards: torch.Tensor,
+ response_mask: torch.Tensor,
+ index: Sequence[Any] | np.ndarray | torch.Tensor,
+ eps: float = 1e-6,
+ *,
+ scale_rewards: bool = True,
+ **_: Any,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """Compatibility wrapper matching the common trainer estimator contract."""
+
+ advantages = compute_grpo_advantages(
+ token_level_rewards,
+ index,
+ response_mask,
+ config=GRPOConfig(normalize=scale_rewards, eps=eps),
+ )
+ return advantages, advantages
+
+
+@torch.no_grad()
+def raw_center_on_policy(
+ rewards: torch.Tensor,
+ is_oracle_row: Sequence[bool] | np.ndarray | torch.Tensor,
+) -> torch.Tensor:
+ """Raw-center policy rewards while assigning zero to the oracle row."""
+
+ validate_floating_tensor("rewards", rewards)
+ if rewards.ndim != 1:
+ raise ValueError("rewards must be one-dimensional.")
+ oracle = boolean_mask(
+ is_oracle_row,
+ rewards.numel(),
+ device=rewards.device,
+ name="is_oracle_row",
+ )
+ oracle_rows = int(oracle.sum().item())
+ if oracle_rows != 1:
+ raise ValueError(f"each OraRL group requires one oracle row, got {oracle_rows}.")
+ policy = ~oracle
+ if not bool(policy.any()):
+ raise ValueError("each OraRL group requires at least one policy row.")
+ output = torch.zeros_like(rewards)
+ policy_rewards = rewards.detach()[policy]
+ output[policy] = policy_rewards - policy_rewards.mean()
+ return output
+
+
+@torch.no_grad()
+def compute_directional_gain(
+ sigma_all: torch.Tensor | float,
+ sigma_policy: torch.Tensor | float,
+ *,
+ config: DirectionalGainConfig | None = None,
+) -> torch.Tensor:
+ """Compute ``clip((sigma_all / (sigma_policy + eps))**gamma, 1, 4)``."""
+
+ cfg = DirectionalGainConfig() if config is None else config
+ if isinstance(sigma_all, torch.Tensor):
+ all_value = sigma_all.detach()
+ else:
+ all_value = torch.tensor(float(sigma_all), dtype=torch.float32)
+ if all_value.ndim != 0:
+ raise ValueError("sigma_all must be scalar.")
+ policy_value = torch.as_tensor(
+ sigma_policy,
+ dtype=all_value.dtype,
+ device=all_value.device,
+ ).detach()
+ if policy_value.ndim != 0:
+ raise ValueError("sigma_policy must be scalar.")
+ if (
+ not bool(torch.isfinite(all_value))
+ or not bool(torch.isfinite(policy_value))
+ or float(all_value.item()) < 0.0
+ or float(policy_value.item()) < 0.0
+ ):
+ raise ValueError("standard deviations must be finite and non-negative.")
+ ratio = all_value / (policy_value + cfg.eps)
+ return torch.clamp(
+ ratio.pow(cfg.gamma),
+ min=DIRECTIONAL_GAIN_MIN,
+ max=DIRECTIONAL_GAIN_MAX,
+ )
+
+
+directional_gain = compute_directional_gain
+
+
+@torch.no_grad()
+def transform_directional_utility(
+ centered_policy_advantages: torch.Tensor,
+ gain: torch.Tensor | float,
+) -> torch.Tensor:
+ """Amplify positive policy utility only, then restore zero mean."""
+
+ validate_floating_tensor(
+ "centered_policy_advantages",
+ centered_policy_advantages,
+ )
+ if centered_policy_advantages.ndim != 1:
+ raise ValueError("centered_policy_advantages must be one-dimensional.")
+ if centered_policy_advantages.numel() == 0:
+ raise ValueError("centered_policy_advantages must not be empty.")
+ gain_value = torch.as_tensor(
+ gain,
+ dtype=centered_policy_advantages.dtype,
+ device=centered_policy_advantages.device,
+ ).detach()
+ if gain_value.ndim != 0 or not bool(torch.isfinite(gain_value)):
+ raise ValueError("gain must be a finite scalar.")
+ if float(gain_value.item()) < 1.0:
+ raise ValueError("gain must be at least one.")
+ base = centered_policy_advantages.detach()
+ transformed = torch.where(base > 0, base * gain_value, base)
+ return transformed - transformed.mean()
+
+
+def _orarl_policy_group(
+ rewards: torch.Tensor,
+ oracle: torch.Tensor,
+ config: DirectionalGainConfig,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ centered = raw_center_on_policy(rewards, oracle)
+ policy = ~oracle
+ gain = compute_directional_gain(
+ sample_std(rewards),
+ sample_std(rewards[policy]),
+ config=config,
+ )
+ output = torch.zeros_like(rewards)
+ output[policy] = transform_directional_utility(centered[policy], gain)
+ return output, gain
+
+
+@torch.no_grad()
+def compute_orarl_policy_advantages(
+ rewards: torch.Tensor,
+ group_ids: Sequence[Any] | np.ndarray | torch.Tensor,
+ is_oracle_row: Sequence[bool] | np.ndarray | torch.Tensor,
+ response_mask: torch.Tensor | None = None,
+ *,
+ config: DirectionalGainConfig | None = None,
+ metrics_out: dict[str, float] | None = None,
+) -> torch.Tensor:
+ """Apply raw policy centering and directional gain to grouped rewards."""
+
+ cfg = DirectionalGainConfig() if config is None else config
+ scores, token_input = sequence_rewards(rewards, response_mask)
+ oracle = boolean_mask(
+ is_oracle_row,
+ scores.numel(),
+ device=scores.device,
+ name="is_oracle_row",
+ )
+ grouped = group_rows(group_ids, scores.numel())
+ output = torch.empty_like(scores)
+ gains: list[float] = []
+ for rows in grouped.values():
+ row_index = torch.tensor(rows, dtype=torch.long, device=scores.device)
+ group_output, gain = _orarl_policy_group(
+ scores.index_select(0, row_index),
+ oracle.index_select(0, row_index),
+ cfg,
+ )
+ output.index_copy_(0, row_index, group_output)
+ gains.append(float(gain.item()))
+ if metrics_out is not None and gains:
+ metrics_out["orarl/directional_gain_mean"] = sum(gains) / len(gains)
+ metrics_out["orarl/directional_gain_max"] = max(gains)
+ metrics_out["orarl/groups"] = float(len(gains))
+ if not token_input:
+ return output
+ assert response_mask is not None
+ return broadcast_sequence_values(output, response_mask, dtype=rewards.dtype)
+
+
+@torch.no_grad()
+def normalized_reward_gap(
+ oracle_reward: torch.Tensor | float,
+ policy_rewards: torch.Tensor,
+ *,
+ beta: float = 2.0,
+ eps: float = 1e-6,
+) -> torch.Tensor:
+ """Return the clipped, task-scale-normalized oracle reward gap."""
+
+ validate_floating_tensor("policy_rewards", policy_rewards)
+ if policy_rewards.ndim != 1 or policy_rewards.numel() == 0:
+ raise ValueError("policy_rewards must be a non-empty vector.")
+ if beta <= 0.0 or eps <= 0.0:
+ raise ValueError("beta and eps must be positive.")
+ oracle = torch.as_tensor(
+ oracle_reward,
+ dtype=policy_rewards.dtype,
+ device=policy_rewards.device,
+ ).detach()
+ if oracle.ndim != 0 or not bool(torch.isfinite(oracle)):
+ raise ValueError("oracle_reward must be a finite scalar.")
+ gap = (oracle - policy_rewards.detach().mean()) / (oracle + eps)
+ return torch.clamp(gap, min=0.0, max=1.0).pow(beta)
+
+
+def _detached_oracle_details(
+ oracle_reward: torch.Tensor | float,
+ policy_rewards: torch.Tensor,
+ policy_advantages: torch.Tensor,
+ config: DetachedOracleConfig,
+) -> tuple[torch.Tensor, dict[str, float]]:
+ validate_floating_tensor("policy_advantages", policy_advantages)
+ if policy_advantages.ndim != 1 or policy_advantages.numel() == 0:
+ raise ValueError("policy_advantages must be a non-empty vector.")
+ if policy_advantages.shape != policy_rewards.shape:
+ raise ValueError("policy rewards and advantages must have matching shapes.")
+
+ weight = normalized_reward_gap(
+ oracle_reward,
+ policy_rewards,
+ beta=config.gap_beta,
+ eps=config.eps,
+ )
+ raw = weight * config.scale
+ anchor = raw
+ positives = policy_advantages.detach()[policy_advantages.detach() > 0]
+ best_positive = positives.max() if positives.numel() > 0 else policy_advantages.new_zeros(())
+ cap = policy_advantages.new_tensor(float("inf"))
+ cap_applied = False
+ if config.match_best_ratio is not None:
+ proposed = (
+ config.match_best_ratio * best_positive
+ if float(best_positive.item()) > 0.0
+ else policy_advantages.new_tensor(config.match_best_min)
+ )
+ cap = torch.clamp(
+ proposed,
+ min=config.match_best_min,
+ max=config.match_best_max,
+ )
+ anchor = torch.minimum(raw, cap)
+ cap_applied = float(anchor.item()) < float(raw.item())
+ details = {
+ "weight": float(weight.item()),
+ "raw": float(raw.item()),
+ "anchor": float(anchor.item()),
+ "best_positive": float(best_positive.item()),
+ "cap": float(cap.item()),
+ "cap_applied": float(cap_applied),
+ }
+ return anchor.detach(), details
+
+
+@torch.no_grad()
+def detached_oracle_advantage(
+ oracle_reward: torch.Tensor | float,
+ policy_rewards: torch.Tensor,
+ policy_advantages: torch.Tensor,
+ *,
+ config: DetachedOracleConfig | None = None,
+) -> torch.Tensor:
+ """Construct a detached oracle advantage with an optional positive cap."""
+
+ cfg = DetachedOracleConfig() if config is None else config
+ anchor, _ = _detached_oracle_details(
+ oracle_reward,
+ policy_rewards,
+ policy_advantages,
+ cfg,
+ )
+ return anchor
+
+
+def _write_oracle_metrics(
+ metrics_out: dict[str, float],
+ details: list[dict[str, float]],
+) -> None:
+ if not details:
+ return
+ count = len(details)
+ for source, target in (
+ ("weight", "orarl/oracle_gap_weight_mean"),
+ ("raw", "orarl/oracle_advantage_raw_mean"),
+ ("anchor", "orarl/oracle_advantage_mean"),
+ ("best_positive", "orarl/best_positive_advantage_mean"),
+ ("cap_applied", "orarl/oracle_cap_applied_fraction"),
+ ):
+ metrics_out[target] = sum(item[source] for item in details) / count
+ finite_caps = [item["cap"] for item in details if np.isfinite(item["cap"])]
+ if finite_caps:
+ metrics_out["orarl/oracle_cap_mean"] = sum(finite_caps) / len(finite_caps)
+
+
+def _compute_orarl_from_scores(
+ policy_scores: torch.Tensor,
+ oracle_scores: torch.Tensor,
+ grouped: dict[Any, list[int]],
+ oracle: torch.Tensor,
+ config: OraRLConfig,
+ metrics_out: dict[str, float] | None,
+) -> torch.Tensor:
+ output = torch.empty_like(policy_scores)
+ gains: list[float] = []
+ details: list[dict[str, float]] = []
+ for rows in grouped.values():
+ row_index = torch.tensor(rows, dtype=torch.long, device=policy_scores.device)
+ group_oracle = oracle.index_select(0, row_index)
+ group_policy_output, gain = _orarl_policy_group(
+ policy_scores.index_select(0, row_index),
+ group_oracle,
+ config.directional_gain,
+ )
+ policy = ~group_oracle
+ raw_group_scores = oracle_scores.index_select(0, row_index)
+ anchor, group_details = _detached_oracle_details(
+ raw_group_scores[group_oracle].squeeze(0),
+ raw_group_scores[policy],
+ group_policy_output[policy],
+ config.detached_oracle,
+ )
+ group_policy_output[group_oracle] = anchor
+ output.index_copy_(0, row_index, group_policy_output)
+ gains.append(float(gain.item()))
+ details.append(group_details)
+
+ if metrics_out is not None and gains:
+ metrics_out["orarl/directional_gain_mean"] = sum(gains) / len(gains)
+ metrics_out["orarl/directional_gain_max"] = max(gains)
+ metrics_out["orarl/groups"] = float(len(gains))
+ _write_oracle_metrics(metrics_out, details)
+ return output
+
+
+@torch.no_grad()
+def compute_orarl_advantages(
+ rewards: torch.Tensor,
+ group_ids: Sequence[Any] | np.ndarray | torch.Tensor,
+ is_oracle_row: Sequence[bool] | np.ndarray | torch.Tensor,
+ response_mask: torch.Tensor | None = None,
+ *,
+ config: OraRLConfig | None = None,
+ metrics_out: dict[str, float] | None = None,
+) -> torch.Tensor:
+ """Compute raw-centered, directionally transformed OraRL advantages."""
+
+ cfg = OraRLConfig() if config is None else config
+ scores, token_input = sequence_rewards(rewards, response_mask)
+ oracle = boolean_mask(
+ is_oracle_row,
+ scores.numel(),
+ device=scores.device,
+ name="is_oracle_row",
+ )
+ grouped = group_rows(group_ids, scores.numel())
+ output = _compute_orarl_from_scores(
+ scores,
+ scores,
+ grouped,
+ oracle,
+ cfg,
+ metrics_out,
+ )
+ if not token_input:
+ return output
+ assert response_mask is not None
+ return broadcast_sequence_values(output, response_mask, dtype=rewards.dtype)
+
+
+@torch.no_grad()
+def apply_grpo_advantages(
+ data: Any,
+ *,
+ config: GRPOConfig | None = None,
+ reward_key: str | None = "token_level_rewards",
+ group_key: str = "uid",
+) -> dict[str, float]:
+ """Write vanilla GRPO advantages into a DataProto-like batch."""
+
+ rewards = batch_reward_tokens(data, reward_key)
+ response_mask = data.batch["response_mask"]
+ group_ids = data.non_tensor_batch[group_key]
+ advantages = compute_grpo_advantages(
+ rewards,
+ group_ids,
+ response_mask,
+ config=config,
+ )
+ data.batch["advantages"] = advantages
+ data.batch["returns"] = advantages
+ return {
+ "grpo/groups": float(len(group_rows(group_ids, advantages.shape[0]))),
+ }
+
+
+@torch.no_grad()
+def apply_detached_oracle_advantage(
+ data: Any,
+ *,
+ config: DetachedOracleConfig | None = None,
+ reward_key: str | None = None,
+ group_key: str = "uid",
+ oracle_key: str = "is_oracle_row",
+) -> dict[str, float]:
+ """Overwrite oracle rows in a DataProto-like batch with detached anchors."""
+
+ cfg = DetachedOracleConfig() if config is None else config
+ advantages = data.batch["advantages"]
+ response_mask = data.batch["response_mask"]
+ sequence_values = sequence_advantages(advantages, response_mask)
+ reward_tokens = batch_reward_tokens(data, reward_key)
+ reward_values, _ = sequence_rewards(reward_tokens, response_mask)
+ grouped = group_rows(data.non_tensor_batch[group_key], advantages.shape[0])
+ oracle = boolean_mask(
+ data.non_tensor_batch[oracle_key],
+ advantages.shape[0],
+ device=advantages.device,
+ name=oracle_key,
+ )
+ updated = advantages.detach().clone()
+ details: list[dict[str, float]] = []
+ for rows in grouped.values():
+ row_index = torch.tensor(rows, dtype=torch.long, device=advantages.device)
+ group_oracle = oracle.index_select(0, row_index)
+ if int(group_oracle.sum().item()) != 1:
+ raise ValueError("each OraRL group requires exactly one oracle row.")
+ policy = ~group_oracle
+ group_rewards = reward_values.index_select(0, row_index)
+ group_advantages = sequence_values.index_select(0, row_index)
+ anchor, group_details = _detached_oracle_details(
+ group_rewards[group_oracle].squeeze(0),
+ group_rewards[policy],
+ group_advantages[policy],
+ cfg,
+ )
+ oracle_global = row_index[group_oracle]
+ updated.index_copy_(
+ 0,
+ oracle_global,
+ broadcast_sequence_values(
+ anchor.reshape(1),
+ response_mask.index_select(0, oracle_global),
+ dtype=advantages.dtype,
+ ),
+ )
+ details.append(group_details)
+ data.batch["advantages"] = updated
+ metrics: dict[str, float] = {"orarl/oracle_groups": float(len(details))}
+ _write_oracle_metrics(metrics, details)
+ return metrics
+
+
+@torch.no_grad()
+def apply_orarl_advantages(
+ data: Any,
+ *,
+ config: OraRLConfig | None = None,
+ policy_reward_key: str | None = "token_level_rewards",
+ oracle_reward_key: str | None = None,
+ group_key: str = "uid",
+ oracle_key: str = "is_oracle_row",
+) -> dict[str, float]:
+ """Write the complete pre-selection OraRL advantage into a batch."""
+
+ cfg = OraRLConfig() if config is None else config
+ response_mask = data.batch["response_mask"]
+ policy_tokens = batch_reward_tokens(data, policy_reward_key)
+ oracle_tokens = batch_reward_tokens(data, oracle_reward_key)
+ policy_scores, _ = sequence_rewards(policy_tokens, response_mask)
+ oracle_scores, _ = sequence_rewards(oracle_tokens, response_mask)
+ group_ids = data.non_tensor_batch[group_key]
+ grouped = group_rows(group_ids, policy_scores.numel())
+ oracle = boolean_mask(
+ data.non_tensor_batch[oracle_key],
+ policy_scores.numel(),
+ device=policy_scores.device,
+ name=oracle_key,
+ )
+ metrics: dict[str, float] = {}
+ sequence_output = _compute_orarl_from_scores(
+ policy_scores,
+ oracle_scores.to(
+ device=policy_scores.device,
+ dtype=policy_scores.dtype,
+ ),
+ grouped,
+ oracle,
+ cfg,
+ metrics,
+ )
+ advantages = broadcast_sequence_values(
+ sequence_output,
+ response_mask,
+ dtype=policy_tokens.dtype,
+ )
+ data.batch["advantages"] = advantages
+ data.batch["returns"] = advantages
+ return metrics
diff --git a/code/orarl/algorithm/config.py b/code/orarl/algorithm/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..3a4434a2ab35633b602c26663d22c3e61a0754ba
--- /dev/null
+++ b/code/orarl/algorithm/config.py
@@ -0,0 +1,142 @@
+# Copyright 2026 The OraRL Authors
+#
+# 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.
+"""Typed configuration for the dependency-light algorithm helpers."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass, field
+
+
+def _positive_finite(name: str, value: float) -> None:
+ if not math.isfinite(value) or value <= 0.0:
+ raise ValueError(f"{name} must be finite and positive, got {value}.")
+
+
+def _nonnegative_finite(name: str, value: float) -> None:
+ if not math.isfinite(value) or value < 0.0:
+ raise ValueError(f"{name} must be finite and non-negative, got {value}.")
+
+
+@dataclass(frozen=True, slots=True)
+class GRPOConfig:
+ """Configuration for the unmodified group-relative baseline."""
+
+ normalize: bool = True
+ eps: float = 1e-6
+
+ def __post_init__(self) -> None:
+ _positive_finite("eps", self.eps)
+
+
+@dataclass(frozen=True, slots=True)
+class DirectionalGainConfig:
+ """Configuration for OraRL's positive-direction utility transform."""
+
+ gamma: float = 0.25
+ eps: float = 1e-6
+
+ def __post_init__(self) -> None:
+ _nonnegative_finite("gamma", self.gamma)
+ _positive_finite("eps", self.eps)
+
+
+@dataclass(frozen=True, slots=True)
+class DetachedOracleConfig:
+ """Configuration for the detached annotation advantage."""
+
+ scale: float = 2.0
+ gap_beta: float = 2.0
+ match_best_ratio: float | None = 1.2
+ match_best_min: float = 0.05
+ match_best_max: float = 1.0
+ eps: float = 1e-6
+
+ def __post_init__(self) -> None:
+ _nonnegative_finite("scale", self.scale)
+ _positive_finite("gap_beta", self.gap_beta)
+ _positive_finite("eps", self.eps)
+ _nonnegative_finite("match_best_min", self.match_best_min)
+ _nonnegative_finite("match_best_max", self.match_best_max)
+ if self.match_best_min > self.match_best_max:
+ raise ValueError("match_best_min must not exceed match_best_max.")
+ if self.match_best_ratio is not None:
+ _positive_finite("match_best_ratio", self.match_best_ratio)
+
+
+@dataclass(frozen=True, slots=True)
+class SelectionConfig:
+ """Strict sign-balanced selection settings.
+
+ The annotation row occupies one slot in ``keep_per_group``. The remaining
+ slots are assigned to the positive and negative policy quotas.
+ """
+
+ keep_per_group: int = 4
+ positive_quota: int = 1
+ negative_quota: int = 2
+ world_size: int = 1
+
+ def __post_init__(self) -> None:
+ if self.keep_per_group < 2:
+ raise ValueError("keep_per_group must leave room for policy and oracle rows.")
+ if self.positive_quota < 0 or self.negative_quota < 0:
+ raise ValueError("selection quotas must be non-negative.")
+ if self.positive_quota + self.negative_quota + 1 != self.keep_per_group:
+ raise ValueError(
+ "positive_quota + negative_quota + one oracle slot must equal keep_per_group."
+ )
+ if self.world_size <= 0:
+ raise ValueError("world_size must be positive.")
+
+
+@dataclass(frozen=True, slots=True)
+class CorrectionConfig:
+ """Settings for post-selection mean and RMS correction."""
+
+ rms_match: bool = True
+ rms_min_scale: float = 0.25
+ sigma_policy_floor: float = 1e-3
+ eps: float = 1e-6
+
+ def __post_init__(self) -> None:
+ if not 0.0 <= self.rms_min_scale <= 1.0:
+ raise ValueError("rms_min_scale must be in [0, 1].")
+ _nonnegative_finite("sigma_policy_floor", self.sigma_policy_floor)
+ _positive_finite("eps", self.eps)
+
+
+@dataclass(frozen=True, slots=True)
+class PostSelectionReference:
+ """Pre-selection statistics for one rollout group."""
+
+ policy_rms: float
+ sigma_policy: float
+ policy_rows: int
+
+ def __post_init__(self) -> None:
+ _nonnegative_finite("policy_rms", self.policy_rms)
+ _nonnegative_finite("sigma_policy", self.sigma_policy)
+ if self.policy_rows < 1:
+ raise ValueError("policy_rows must be positive.")
+
+
+@dataclass(frozen=True, slots=True)
+class OraRLConfig:
+ """Paper recipe defaults for the four numerical stages."""
+
+ directional_gain: DirectionalGainConfig = field(default_factory=DirectionalGainConfig)
+ detached_oracle: DetachedOracleConfig = field(default_factory=DetachedOracleConfig)
+ selection: SelectionConfig = field(default_factory=SelectionConfig)
+ correction: CorrectionConfig = field(default_factory=CorrectionConfig)
diff --git a/code/orarl/algorithm/correction.py b/code/orarl/algorithm/correction.py
new file mode 100644
index 0000000000000000000000000000000000000000..f0cebc55a4dbc73fcf07af08e32bb421da2fe6b1
--- /dev/null
+++ b/code/orarl/algorithm/correction.py
@@ -0,0 +1,283 @@
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Copyright 2026 The OraRL Authors
+#
+# 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.
+"""Pre-selection references and post-selection moment correction.
+
+The projection and RMS-downscale construction is derived from the Apache-2.0
+EasyR1/verl implementation.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import torch
+
+from ._utils import (
+ batch_reward_tokens,
+ boolean_mask,
+ broadcast_sequence_values,
+ group_rows,
+ sample_std,
+ sequence_advantages,
+ sequence_rewards,
+ validate_floating_tensor,
+)
+from .config import CorrectionConfig, PostSelectionReference
+
+
+def _resolved_config(
+ config: CorrectionConfig | None,
+ rms_match: bool | None,
+ rms_min_scale: float | None,
+ sigma_policy_floor: float | None,
+ eps: float | None,
+) -> CorrectionConfig:
+ base = CorrectionConfig() if config is None else config
+ return CorrectionConfig(
+ rms_match=base.rms_match if rms_match is None else rms_match,
+ rms_min_scale=(base.rms_min_scale if rms_min_scale is None else rms_min_scale),
+ sigma_policy_floor=(
+ base.sigma_policy_floor if sigma_policy_floor is None else sigma_policy_floor
+ ),
+ eps=base.eps if eps is None else eps,
+ )
+
+
+@torch.no_grad()
+def capture_pre_selection_references(
+ data: Any,
+ *,
+ reward_key: str | None = None,
+ group_key: str = "uid",
+ oracle_key: str = "is_oracle_row",
+) -> dict[Any, PostSelectionReference]:
+ """Capture policy RMS and reward spread before rows are selected."""
+
+ required = ("advantages", "response_mask")
+ missing = [key for key in required if key not in data.batch]
+ if missing:
+ raise ValueError(f"reference capture is missing batch keys: {missing}.")
+ if group_key not in data.non_tensor_batch:
+ raise ValueError(f"reference capture requires {group_key!r}.")
+ if oracle_key not in data.non_tensor_batch:
+ raise ValueError(f"reference capture requires {oracle_key!r}.")
+
+ advantages = data.batch["advantages"]
+ response_mask = data.batch["response_mask"]
+ sequence_values = sequence_advantages(advantages, response_mask).float()
+ reward_tokens = batch_reward_tokens(data, reward_key)
+ reward_values, _ = sequence_rewards(reward_tokens, response_mask)
+ reward_values = reward_values.to(
+ device=sequence_values.device,
+ dtype=torch.float32,
+ )
+ grouped = group_rows(
+ data.non_tensor_batch[group_key],
+ sequence_values.numel(),
+ )
+ oracle = boolean_mask(
+ data.non_tensor_batch[oracle_key],
+ sequence_values.numel(),
+ device=sequence_values.device,
+ name=oracle_key,
+ )
+
+ references: dict[Any, PostSelectionReference] = {}
+ for group_id, rows in grouped.items():
+ row_index = torch.tensor(
+ rows,
+ dtype=torch.long,
+ device=sequence_values.device,
+ )
+ group_oracle = oracle.index_select(0, row_index)
+ if int(group_oracle.sum().item()) != 1:
+ raise ValueError(f"group {group_id!r} requires exactly one oracle row.")
+ policy = ~group_oracle
+ policy_advantages = sequence_values.index_select(0, row_index)[policy]
+ policy_rewards = reward_values.index_select(0, row_index)[policy]
+ references[group_id] = PostSelectionReference(
+ policy_rms=float(torch.sqrt(torch.mean(policy_advantages.square())).item()),
+ sigma_policy=float(sample_std(policy_rewards).item()),
+ policy_rows=int(policy.sum().item()),
+ )
+ return references
+
+
+build_pre_selection_references = capture_pre_selection_references
+
+
+@torch.no_grad()
+def correct_post_selection_group(
+ active_advantages: torch.Tensor,
+ is_oracle_row: torch.Tensor,
+ *,
+ reference: PostSelectionReference,
+ config: CorrectionConfig | None = None,
+ rms_match: bool | None = None,
+ rms_min_scale: float | None = None,
+ sigma_policy_floor: float | None = None,
+ eps: float | None = None,
+) -> tuple[torch.Tensor, dict[str, float]]:
+ """Zero-center one selected group and optionally downscale its RMS."""
+
+ cfg = _resolved_config(
+ config,
+ rms_match,
+ rms_min_scale,
+ sigma_policy_floor,
+ eps,
+ )
+ validate_floating_tensor("active_advantages", active_advantages)
+ if active_advantages.ndim != 1 or active_advantages.numel() == 0:
+ raise ValueError("active_advantages must be a non-empty vector.")
+ oracle = boolean_mask(
+ is_oracle_row,
+ active_advantages.numel(),
+ device=active_advantages.device,
+ name="is_oracle_row",
+ )
+ oracle_rows = int(oracle.sum().item())
+ if oracle_rows != 1:
+ raise ValueError(f"post-selection correction requires one oracle row, got {oracle_rows}.")
+ policy = ~oracle
+ policy_rows = int(policy.sum().item())
+ if policy_rows < 1:
+ raise ValueError("post-selection correction requires a policy row.")
+
+ before = active_advantages.detach()
+ mean_before = before.mean()
+ rms_before = torch.sqrt(torch.mean(before.square()))
+ corrected = before - mean_before
+ projected = False
+ if float(corrected[oracle].item()) < 0.0:
+ correction = -corrected[oracle].squeeze(0)
+ corrected[oracle] = 0.0
+ corrected[policy] -= correction / policy_rows
+ projected = True
+
+ small_sigma_fallback = reference.sigma_policy < cfg.sigma_policy_floor
+ rms_scale = corrected.new_ones(())
+ if cfg.rms_match and not small_sigma_fallback:
+ active_rms = torch.sqrt(torch.mean(corrected.square()))
+ if float(active_rms.item()) > cfg.eps:
+ target = corrected.new_tensor(reference.policy_rms)
+ rms_scale = torch.clamp(
+ target / (active_rms + cfg.eps),
+ min=cfg.rms_min_scale,
+ max=1.0,
+ )
+ corrected = corrected * rms_scale
+
+ mean_after = corrected.mean()
+ rms_after = torch.sqrt(torch.mean(corrected.square()))
+ metrics = {
+ "active_mean_before": float(mean_before.item()),
+ "active_rms_before": float(rms_before.item()),
+ "policy_rms_reference": reference.policy_rms,
+ "rms_scale": float(rms_scale.item()),
+ "oracle_sign_projection": float(projected),
+ "small_sigma_fallback": float(small_sigma_fallback),
+ "active_mean_after": float(mean_after.item()),
+ "active_rms_after": float(rms_after.item()),
+ "oracle_advantage_after": float(corrected[oracle].item()),
+ "active_rows": float(corrected.numel()),
+ "active_policy_rows": float(policy_rows),
+ }
+ return corrected, metrics
+
+
+balance_post_selection_group = correct_post_selection_group
+
+
+@torch.no_grad()
+def apply_post_selection_correction(
+ data: Any,
+ references: dict[Any, PostSelectionReference],
+ *,
+ config: CorrectionConfig | None = None,
+ rms_match: bool | None = None,
+ rms_min_scale: float | None = None,
+ sigma_policy_floor: float | None = None,
+ eps: float | None = None,
+ group_key: str = "uid",
+ oracle_key: str = "is_oracle_row",
+) -> dict[str, float]:
+ """Apply post-selection correction to every DataProto-like group."""
+
+ cfg = _resolved_config(
+ config,
+ rms_match,
+ rms_min_scale,
+ sigma_policy_floor,
+ eps,
+ )
+ if "advantages" not in data.batch or "response_mask" not in data.batch:
+ raise ValueError("correction requires advantages and response_mask.")
+ advantages = data.batch["advantages"]
+ response_mask = data.batch["response_mask"]
+ sequence_values = sequence_advantages(advantages, response_mask).float()
+ grouped = group_rows(
+ data.non_tensor_batch[group_key],
+ sequence_values.numel(),
+ )
+ oracle = boolean_mask(
+ data.non_tensor_batch[oracle_key],
+ sequence_values.numel(),
+ device=sequence_values.device,
+ name=oracle_key,
+ )
+
+ updated = advantages.detach().clone()
+ collected: dict[str, list[float]] = {}
+ for group_id, rows in grouped.items():
+ if group_id not in references:
+ raise ValueError(f"missing pre-selection reference for {group_id!r}.")
+ row_index = torch.tensor(
+ rows,
+ dtype=torch.long,
+ device=sequence_values.device,
+ )
+ corrected, group_metrics = correct_post_selection_group(
+ sequence_values.index_select(0, row_index),
+ oracle.index_select(0, row_index),
+ reference=references[group_id],
+ config=cfg,
+ )
+ updated.index_copy_(
+ 0,
+ row_index.to(device=updated.device),
+ broadcast_sequence_values(
+ corrected.to(device=updated.device),
+ response_mask.index_select(
+ 0,
+ row_index.to(device=response_mask.device),
+ ),
+ dtype=advantages.dtype,
+ ),
+ )
+ for key, value in group_metrics.items():
+ collected.setdefault(key, []).append(value)
+
+ data.batch["advantages"] = updated
+ metrics: dict[str, float] = {
+ "orarl/post_selection_groups": float(len(grouped)),
+ "orarl/post_selection_rms_match": float(cfg.rms_match),
+ }
+ for key, values in collected.items():
+ metrics[f"orarl/post_selection_{key}"] = sum(values) / len(values)
+ return metrics
+
+
+apply_post_selection_advantage_correction = apply_post_selection_correction
diff --git a/code/orarl/algorithm/selection.py b/code/orarl/algorithm/selection.py
new file mode 100644
index 0000000000000000000000000000000000000000..e081918c53a85ec7af5b7906756f8a3541e57431
--- /dev/null
+++ b/code/orarl/algorithm/selection.py
@@ -0,0 +1,238 @@
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Copyright 2026 The OraRL Authors
+#
+# 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.
+"""Strict sign-balanced rollout selection.
+
+The physical batch-slicing pattern is derived from the Apache-2.0 EasyR1/verl
+trainer. The selector here is limited to the OraRL paper recipe.
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+import torch
+
+from ._utils import (
+ boolean_mask,
+ copy_and_refresh_meta,
+ group_rows,
+ sequence_advantages,
+)
+from .config import SelectionConfig
+
+
+def _resolved_config(
+ config: SelectionConfig | None,
+ keep_per_group: int | None,
+ positive_quota: int | None,
+ negative_quota: int | None,
+ world_size: int | None,
+ n_rollouts: int | None,
+ prune_ratio: float | None,
+) -> SelectionConfig:
+ base = SelectionConfig() if config is None else config
+ if (n_rollouts is None) != (prune_ratio is None):
+ raise ValueError("n_rollouts and prune_ratio must be provided together.")
+ derived_keep: int | None = None
+ if n_rollouts is not None and prune_ratio is not None:
+ if n_rollouts <= 1:
+ raise ValueError("n_rollouts must be greater than one.")
+ if not math.isfinite(prune_ratio) or not 0.0 <= prune_ratio < 1.0:
+ raise ValueError("prune_ratio must be finite and in [0, 1).")
+ derived_keep = int(n_rollouts * (1.0 - prune_ratio))
+ if keep_per_group is not None and keep_per_group != derived_keep:
+ raise ValueError("keep_per_group disagrees with the n_rollouts/prune_ratio budget.")
+ resolved_keep = (
+ derived_keep
+ if derived_keep is not None
+ else base.keep_per_group
+ if keep_per_group is None
+ else keep_per_group
+ )
+ return SelectionConfig(
+ keep_per_group=resolved_keep,
+ positive_quota=(base.positive_quota if positive_quota is None else positive_quota),
+ negative_quota=(base.negative_quota if negative_quota is None else negative_quota),
+ world_size=base.world_size if world_size is None else world_size,
+ )
+
+
+def _rank_by_magnitude(rows: list[int], scores: torch.Tensor) -> list[int]:
+ return sorted(
+ rows,
+ key=lambda row: (-abs(float(scores[row].item())), row),
+ )
+
+
+@torch.no_grad()
+def select_sign_balanced_rollouts(
+ data: Any,
+ keep_per_group: int | None = None,
+ positive_quota: int | None = None,
+ negative_quota: int | None = None,
+ world_size: int | None = None,
+ *,
+ config: SelectionConfig | None = None,
+ n_rollouts: int | None = None,
+ prune_ratio: float | None = None,
+ group_key: str = "uid",
+ oracle_key: str = "is_oracle_row",
+) -> tuple[Any, dict[str, float]]:
+ """Select exactly one oracle plus strict positive/negative policy quotas.
+
+ A short sign bucket is filled from the opposite sign by descending
+ magnitude, followed by zero-advantage rows. The selected indices physically
+ slice the DataProto-like object before the actor update.
+ """
+
+ cfg = _resolved_config(
+ config,
+ keep_per_group,
+ positive_quota,
+ negative_quota,
+ world_size,
+ n_rollouts,
+ prune_ratio,
+ )
+ if "advantages" not in data.batch or "response_mask" not in data.batch:
+ raise ValueError("selection requires advantages and response_mask.")
+ if group_key not in data.non_tensor_batch:
+ raise ValueError(f"selection requires non_tensor_batch[{group_key!r}].")
+ if oracle_key not in data.non_tensor_batch:
+ raise ValueError(f"selection requires non_tensor_batch[{oracle_key!r}].")
+
+ advantages = data.batch["advantages"]
+ response_mask = data.batch["response_mask"]
+ signed_scores = sequence_advantages(advantages, response_mask)
+ total_rows = signed_scores.numel()
+ grouped = group_rows(data.non_tensor_batch[group_key], total_rows)
+ oracle = boolean_mask(
+ data.non_tensor_batch[oracle_key],
+ total_rows,
+ device=signed_scores.device,
+ name=oracle_key,
+ )
+
+ selected_rows: list[int] = []
+ positive_kept = 0
+ negative_kept = 0
+ zero_kept = 0
+ cross_sign_fallback = 0
+ zero_fallback = 0
+
+ for group_id, rows in grouped.items():
+ if len(rows) < cfg.keep_per_group:
+ raise ValueError(
+ f"group {group_id!r} has {len(rows)} rows, fewer than the "
+ f"keep budget {cfg.keep_per_group}."
+ )
+ oracle_rows = [row for row in rows if bool(oracle[row])]
+ if len(oracle_rows) != 1:
+ raise ValueError(
+ f"group {group_id!r} requires exactly one oracle row, got {len(oracle_rows)}."
+ )
+ oracle_row = oracle_rows[0]
+ candidates = [row for row in rows if row != oracle_row]
+ positive = _rank_by_magnitude(
+ [row for row in candidates if float(signed_scores[row].item()) > 0.0],
+ signed_scores,
+ )
+ negative = _rank_by_magnitude(
+ [row for row in candidates if float(signed_scores[row].item()) < 0.0],
+ signed_scores,
+ )
+ zeros = [row for row in candidates if float(signed_scores[row].item()) == 0.0]
+
+ chosen = positive[: cfg.positive_quota] + negative[: cfg.negative_quota]
+ chosen_set = set(chosen)
+ policy_budget = cfg.keep_per_group - 1
+ remaining = policy_budget - len(chosen)
+
+ if remaining > 0:
+ opposite_sign_surplus = _rank_by_magnitude(
+ [row for row in positive + negative if row not in chosen_set],
+ signed_scores,
+ )
+ cross_fill = opposite_sign_surplus[:remaining]
+ chosen.extend(cross_fill)
+ chosen_set.update(cross_fill)
+ cross_sign_fallback += len(cross_fill)
+ remaining -= len(cross_fill)
+
+ if remaining > 0:
+ zero_fill = [row for row in zeros if row not in chosen_set][:remaining]
+ chosen.extend(zero_fill)
+ chosen_set.update(zero_fill)
+ zero_fallback += len(zero_fill)
+ remaining -= len(zero_fill)
+
+ if remaining > 0:
+ final_fill = [row for row in candidates if row not in chosen_set][:remaining]
+ chosen.extend(final_fill)
+ remaining -= len(final_fill)
+ if remaining != 0 or len(chosen) != policy_budget:
+ raise RuntimeError(f"group {group_id!r} could not satisfy its keep budget.")
+
+ for row in chosen:
+ score = float(signed_scores[row].item())
+ positive_kept += int(score > 0.0)
+ negative_kept += int(score < 0.0)
+ zero_kept += int(score == 0.0)
+ selected_rows.extend([oracle_row, *chosen])
+
+ selected_rows.sort()
+ if len(selected_rows) % cfg.world_size != 0:
+ raise RuntimeError(
+ f"selected batch size {len(selected_rows)} is not divisible by "
+ f"world_size {cfg.world_size}."
+ )
+
+ selected = data[selected_rows]
+ copy_and_refresh_meta(selected)
+ selected_index = torch.tensor(
+ selected_rows,
+ dtype=torch.long,
+ device=signed_scores.device,
+ )
+ kept_abs = signed_scores.index_select(0, selected_index).abs()
+ selected_set = set(selected_rows)
+ dropped_rows = [row for row in range(total_rows) if row not in selected_set]
+ metrics: dict[str, float] = {
+ "orarl/selection_groups": float(len(grouped)),
+ "orarl/keep_per_group": float(cfg.keep_per_group),
+ "orarl/kept_rows": float(len(selected_rows)),
+ "orarl/dropped_rows": float(total_rows - len(selected_rows)),
+ "orarl/effective_keep_ratio": len(selected_rows) / float(total_rows),
+ "orarl/oracle_rows_forced": float(len(grouped)),
+ "orarl/positive_policy_rows_kept": float(positive_kept),
+ "orarl/negative_policy_rows_kept": float(negative_kept),
+ "orarl/zero_policy_rows_kept": float(zero_kept),
+ "orarl/cross_sign_fallback_rows": float(cross_sign_fallback),
+ "orarl/zero_fallback_rows": float(zero_fallback),
+ "orarl/abs_advantage_kept_mean": float(kept_abs.mean().item()),
+ }
+ if prune_ratio is not None:
+ metrics["orarl/requested_prune_ratio"] = prune_ratio
+ if dropped_rows:
+ dropped_index = torch.tensor(
+ dropped_rows,
+ dtype=torch.long,
+ device=signed_scores.device,
+ )
+ metrics["orarl/abs_advantage_dropped_mean"] = float(
+ signed_scores.index_select(0, dropped_index).abs().mean().item()
+ )
+ return selected, metrics
diff --git a/code/orarl/cli/__init__.py b/code/orarl/cli/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..317f1ffae31daa84f48f2b69b185b53fdd24fd95
--- /dev/null
+++ b/code/orarl/cli/__init__.py
@@ -0,0 +1 @@
+"""Command-line entry points for OraRL."""
diff --git a/code/orarl/cli/eval_data.py b/code/orarl/cli/eval_data.py
new file mode 100644
index 0000000000000000000000000000000000000000..094f8c83002461eb635e32bea4378feef235ecf5
--- /dev/null
+++ b/code/orarl/cli/eval_data.py
@@ -0,0 +1,357 @@
+"""Inventory, build, and validate canonical OraRL evaluation data."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from collections.abc import Sequence
+
+from orarl.evaluation.card import (
+ DEFAULT_DATASET_REPO_ID,
+ write_huggingface_metadata,
+)
+from orarl.evaluation.hub import upload_evaluation_repository
+from orarl.evaluation.staging import (
+ build_evaluation_repository,
+ export_evaluation_index,
+ export_public_evaluation_repository,
+ inventory_and_write_locked_manifest,
+ inventory_evaluation_sources,
+ merge_evaluation_repository,
+ validate_staged_repository,
+)
+
+
+def _add_checksum_option(parser: argparse.ArgumentParser) -> None:
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument(
+ "--checksums",
+ dest="checksums",
+ action="store_true",
+ help="Opt in to SHA-256 generation or verification.",
+ )
+ group.add_argument(
+ "--skip-checksums",
+ dest="checksums",
+ action="store_false",
+ help=argparse.SUPPRESS,
+ )
+ parser.set_defaults(checksums=False)
+
+
+def create_parser() -> argparse.ArgumentParser:
+ """Create the dependency-light ``orarl-eval-data`` command parser."""
+
+ parser = argparse.ArgumentParser(prog="orarl-eval-data", description=__doc__)
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ inventory = commands.add_parser(
+ "inventory",
+ help="Read source rows and referenced asset sizes without writing output.",
+ )
+ inventory.add_argument("manifest_path", nargs="?")
+ inventory.add_argument(
+ "--manifest",
+ "--source-manifest",
+ dest="manifest",
+ help="Local JSONL source manifest.",
+ )
+ inventory.add_argument(
+ "--write-locked-manifest",
+ metavar="FILE",
+ help="Write a private deterministic manifest with actual converted row counts.",
+ )
+ inventory.add_argument(
+ "--task",
+ action="append",
+ dest="tasks",
+ help="Inventory only one eval task; repeat to select multiple tasks.",
+ )
+ inventory.add_argument(
+ "--workers",
+ type=int,
+ default=8,
+ help="Parallel source-conversion workers (default: 8).",
+ )
+ _add_checksum_option(inventory)
+
+ build = commands.add_parser(
+ "build",
+ help="Build an atomic canonical evaluation repository.",
+ )
+ build.add_argument("manifest_path", nargs="?")
+ build.add_argument("output_path", nargs="?")
+ build.add_argument(
+ "--manifest",
+ "--source-manifest",
+ dest="manifest",
+ help="Local JSONL source manifest.",
+ )
+ build.add_argument("--output", dest="output", help="Target repository directory.")
+ build.add_argument(
+ "--copy-mode",
+ choices=("copy", "hardlink"),
+ default="copy",
+ help="Stage files by copying or hard-linking with copy fallback.",
+ )
+ build.add_argument("--overwrite", action="store_true")
+ build.add_argument(
+ "--workers",
+ type=int,
+ default=8,
+ help="Parallel source-conversion workers (default: 8).",
+ )
+ _add_checksum_option(build)
+
+ merge = commands.add_parser(
+ "merge",
+ help="Add a task repository to an existing canonical repository.",
+ )
+ merge.add_argument("--source", required=True, help="Validated task repository.")
+ merge.add_argument(
+ "--target",
+ "--output",
+ dest="target",
+ required=True,
+ help="Existing cumulative evaluation repository.",
+ )
+ merge.add_argument(
+ "--copy-mode",
+ choices=("copy", "hardlink"),
+ default="hardlink",
+ help="Add files by hard-linking with copy fallback, or always copy.",
+ )
+ merge.add_argument(
+ "--repair-missing-asset-manifest",
+ action="store_true",
+ help=(
+ "Reconstruct a deleted target assets.jsonl from staged paths and "
+ "byte sizes without hashing payloads."
+ ),
+ )
+ merge.add_argument(
+ "--workers",
+ type=int,
+ default=8,
+ help="Parallel file metadata and hardlink workers (default: 8).",
+ )
+ merge.add_argument(
+ "--replace-benchmark",
+ action="append",
+ default=[],
+ help=(
+ "Replace every existing split and asset for this benchmark; repeat "
+ "for multiple benchmarks."
+ ),
+ )
+
+ export_index = commands.add_parser(
+ "export-index",
+ help="Export portable JSONL metadata while keeping large assets external.",
+ )
+ export_index.add_argument(
+ "--root",
+ required=True,
+ help="Complete validated evaluation repository.",
+ )
+ export_index.add_argument(
+ "--output",
+ required=True,
+ help="Target JSONL metadata directory.",
+ )
+ export_index.add_argument("--overwrite", action="store_true")
+ _add_checksum_option(export_index)
+
+ export_public = commands.add_parser(
+ "export-public",
+ help="Export annotations and raw media while excluding processed artifacts.",
+ )
+ export_public.add_argument(
+ "--root",
+ required=True,
+ help="Complete validated evaluation repository.",
+ )
+ export_public.add_argument(
+ "--output",
+ required=True,
+ help="Target public evaluation repository.",
+ )
+ export_public.add_argument(
+ "--copy-mode",
+ choices=("copy", "hardlink"),
+ default="hardlink",
+ help="Stage raw media by hard-linking with copy fallback, or always copy.",
+ )
+ export_public.add_argument("--overwrite", action="store_true")
+ export_public.add_argument(
+ "--workers",
+ type=int,
+ default=8,
+ help="Parallel raw-media staging workers (default: 8).",
+ )
+ _add_checksum_option(export_public)
+
+ metadata = commands.add_parser(
+ "metadata",
+ help="Regenerate and validate Hugging Face README and LFS metadata.",
+ )
+ metadata.add_argument("root_path", nargs="?")
+ metadata.add_argument("--root", help="Staged evaluation repository.")
+ metadata.add_argument(
+ "--repo-id",
+ default=DEFAULT_DATASET_REPO_ID,
+ help="Hugging Face repository id used in the generated card.",
+ )
+ _add_checksum_option(metadata)
+
+ validate = commands.add_parser(
+ "validate",
+ help="Validate manifests, rows, references, and byte sizes.",
+ )
+ validate.add_argument("root_path", nargs="?")
+ validate.add_argument("--root", help="Staged evaluation repository.")
+ validate.add_argument(
+ "--allow-unauthorized",
+ action="store_true",
+ help="Validate local structure without requiring redistribution authorization.",
+ )
+ validate.add_argument(
+ "--repo-id",
+ default=DEFAULT_DATASET_REPO_ID,
+ help="Hugging Face repository id used by generated metadata.",
+ )
+ _add_checksum_option(validate)
+
+ upload = commands.add_parser(
+ "upload",
+ help="Validate and resumably upload a complete Hugging Face dataset repository.",
+ )
+ upload.add_argument("--root", required=True, help="Staged evaluation repository.")
+ upload.add_argument("--repo-id", required=True, help="Hugging Face dataset repository id.")
+ upload.add_argument(
+ "--revision",
+ default="main",
+ help="Target Hugging Face branch or revision (default: main).",
+ )
+ _add_checksum_option(upload)
+ upload.add_argument(
+ "--private",
+ action="store_true",
+ help="Create the dataset repository as private if it does not exist.",
+ )
+ upload.add_argument(
+ "--num-workers",
+ type=int,
+ help="Optional upload worker count.",
+ )
+ return parser
+
+
+def _required(option: str | None, positional: str | None, label: str) -> str:
+ if option and positional and option != positional:
+ raise ValueError(f"conflicting {label} values")
+ value = option or positional
+ if not value:
+ raise ValueError(f"{label} is required")
+ return value
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Run one evaluation-data staging command."""
+
+ args = create_parser().parse_args(argv)
+ try:
+ if args.command == "inventory":
+ manifest = _required(args.manifest, args.manifest_path, "source manifest")
+ if args.write_locked_manifest:
+ result = inventory_and_write_locked_manifest(
+ manifest,
+ args.write_locked_manifest,
+ tasks=args.tasks,
+ workers=args.workers,
+ checksums=args.checksums,
+ )
+ else:
+ result = inventory_evaluation_sources(
+ manifest,
+ tasks=args.tasks,
+ workers=args.workers,
+ checksums=args.checksums,
+ )
+ elif args.command == "build":
+ manifest = _required(args.manifest, args.manifest_path, "source manifest")
+ output = _required(args.output, args.output_path, "output")
+ result = build_evaluation_repository(
+ manifest,
+ output,
+ copy_mode=args.copy_mode,
+ overwrite=args.overwrite,
+ workers=args.workers,
+ checksums=args.checksums,
+ )
+ elif args.command == "merge":
+ result = merge_evaluation_repository(
+ args.source,
+ args.target,
+ copy_mode=args.copy_mode,
+ repair_missing_asset_manifest=args.repair_missing_asset_manifest,
+ workers=args.workers,
+ replace_benchmarks=args.replace_benchmark,
+ )
+ elif args.command == "metadata":
+ root = _required(args.root, args.root_path, "repository root")
+ metadata_result = write_huggingface_metadata(
+ root,
+ repo_id=args.repo_id,
+ )
+ result = {
+ **validate_staged_repository(
+ root,
+ checksums=args.checksums,
+ repo_id=args.repo_id,
+ ),
+ **metadata_result,
+ }
+ elif args.command == "validate":
+ root = _required(args.root, args.root_path, "repository root")
+ result = validate_staged_repository(
+ root,
+ require_redistribution_authorized=not args.allow_unauthorized,
+ checksums=args.checksums,
+ repo_id=args.repo_id,
+ )
+ elif args.command == "export-index":
+ result = export_evaluation_index(
+ args.root,
+ args.output,
+ overwrite=args.overwrite,
+ checksums=args.checksums,
+ )
+ elif args.command == "export-public":
+ result = export_public_evaluation_repository(
+ args.root,
+ args.output,
+ copy_mode=args.copy_mode,
+ overwrite=args.overwrite,
+ workers=args.workers,
+ checksums=args.checksums,
+ )
+ else:
+ result = upload_evaluation_repository(
+ args.root,
+ args.repo_id,
+ revision=args.revision,
+ private=args.private,
+ num_workers=args.num_workers,
+ checksums=args.checksums,
+ )
+ except (FileExistsError, OSError, ValueError) as error:
+ print(f"[FATAL] {error}", file=sys.stderr)
+ return 2
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/code/orarl/cli/evaluate.py b/code/orarl/cli/evaluate.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6227c042c62fed3852e7e9a860a95699ec46fda
--- /dev/null
+++ b/code/orarl/cli/evaluate.py
@@ -0,0 +1,1254 @@
+"""Explicit-path evaluation wrapper for the paper benchmark suite."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import shlex
+import subprocess
+from datetime import datetime, timezone
+from importlib.metadata import PackageNotFoundError, distribution
+from importlib.util import find_spec
+from pathlib import Path, PurePosixPath
+from typing import Any, Mapping, Sequence
+
+import yaml
+
+from orarl.evaluation import (
+ EvaluationSchemaError,
+ ManifestError,
+ StagingError,
+ evaluation_asset_paths,
+ load_asset_manifest,
+ load_dataset_manifest,
+ load_evaluation_jsonl,
+ validate_asset_manifest,
+ validate_dataset_manifest,
+)
+
+VIDEO_QA_TASKS = (
+ "videomme",
+ "videommev2",
+ "mvbench",
+ "mmvu",
+ "videoholmes",
+ "longvideobench",
+ "mlvu",
+)
+PAPER_TASKS = (
+ *VIDEO_QA_TASKS,
+ "vsi",
+ "mmsi",
+ "mindcube",
+ "revsi",
+ "spatial_grounding",
+ "tracking",
+ "stvg",
+ "temporal_grounding",
+ "segmentation",
+)
+_TASK_PREFIX = {
+ **{task: task.upper() for task in VIDEO_QA_TASKS},
+ "vsi": "VSI",
+ "mmsi": "MMSI",
+ "mindcube": "MINDCUBE",
+ "revsi": "REVSI",
+ "spatial_grounding": "SPATIAL_GROUNDING",
+ "tracking": "TRACKING",
+ "stvg": "STVG",
+ "temporal_grounding": "TIMELENS",
+ "segmentation": "SEGMENTATION",
+}
+_REQUIRED_INPUTS = {
+ "videomme": {"VIDEOMME_DATA_FILE", "VIDEOMME_VIDEO_BASE"},
+ "videommev2": {"VIDEOMMEV2_DATA_FILE", "VIDEOMMEV2_VIDEO_BASE"},
+ "mvbench": {"MVBENCH_DATA_FILE", "MVBENCH_VIDEO_ROOT"},
+ "mmvu": {"MMVU_DATA_FILE", "MMVU_VIDEO_ROOT"},
+ "videoholmes": {"VIDEOHOLMES_DATA_FILE", "VIDEOHOLMES_VIDEO_ROOT"},
+ "longvideobench": {"LONGVIDEOBENCH_DATA_FILE", "LONGVIDEOBENCH_VIDEO_ROOT"},
+ "mlvu": {"MLVU_DATA_FILE", "MLVU_VIDEO_ROOT"},
+ "vsi": {"VSI_DATA_FILE", "VSI_PREPROCESSED_VIDEO_DIR"},
+ "mmsi": {"MMSI_DATA_FILE"},
+ "mindcube": {"MINDCUBE_DATA_FILE"},
+ "revsi": {"REVSI_DATA_FILE", "REVSI_VIDEO_ROOT"},
+ "spatial_grounding": {
+ "SPATIAL_GROUNDING_BENCH_DIR",
+ "SPATIAL_GROUNDING_IMAGE_ROOT",
+ },
+ "tracking": {"TRACKING_BENCH_DIR", "TRACKING_BASE_PREFIX"},
+ "stvg": {"STVG_BENCH_DIR", "STVG_BASE_PREFIX"},
+ "temporal_grounding": {"TIMELENS_BENCH_DIR"},
+ "segmentation": {"SEGMENTATION_BENCH_DIR", "SEGMENTATION_DATA_ROOT"},
+}
+_PATH_ENV_SUFFIXES = (
+ "_FILE",
+ "_DIR",
+ "_ROOT",
+ "_PATH",
+ "_CKPT",
+ "_CFG",
+ "_BASE",
+ "_PREFIX",
+)
+_STRUCTURED_TASKS = frozenset(
+ {
+ "spatial_grounding",
+ "tracking",
+ "stvg",
+ "temporal_grounding",
+ "segmentation",
+ }
+)
+_SPATIAL_GROUNDING_SPLIT_NAMES = {
+ "refcoco_val": "refcoco-val",
+ "refcoco_test_a": "refcoco-testA",
+ "refcoco_test_b": "refcoco-testB",
+ "refcocop_val": "refcoco+-val",
+ "refcocop_test_a": "refcoco+-testA",
+ "refcocop_test_b": "refcoco+-testB",
+ "refcocog_val": "refcocog-val",
+ "refcocog_test": "refcocog-test",
+}
+_TRACKING_SPLIT_NAMES = {
+ "got10k": "eval_got10k",
+}
+_STVG_SPLIT_NAMES = {
+ "stvg": "eval_stvg",
+}
+
+
+def _evaluator_split_name(task: str, split: object) -> str:
+ value = str(split)
+ if task == "spatial_grounding":
+ return _SPATIAL_GROUNDING_SPLIT_NAMES.get(value, value)
+ if task == "tracking":
+ return _TRACKING_SPLIT_NAMES.get(value, value)
+ if task == "stvg":
+ return _STVG_SPLIT_NAMES.get(value, value)
+ return value
+
+
+_MANIFEST_FILES = ("datasets.jsonl", "assets.jsonl")
+_INDEX_MANIFEST_FILES = ("datasets.jsonl",)
+_SENSITIVE_NAME = re.compile(
+ r"(?:^|_)(?:api_?key|credential|password|private_?key|secret|token)(?:$|_)",
+ flags=re.IGNORECASE,
+)
+_URI_USERINFO = re.compile(r"://[^/\s:@]+:[^@\s/]+@")
+
+
+class CliError(ValueError):
+ """Raised for invalid evaluation requests."""
+
+
+def create_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="orarl-eval",
+ description=(
+ "Run the repository evaluator with explicit benchmark inputs and "
+ "write one aggregate JSON summary. The default is a dry run."
+ ),
+ )
+ parser.add_argument("--model", required=True, help="Local model or checkpoint path.")
+ parser.add_argument(
+ "--tasks",
+ required=True,
+ help=(
+ "Comma-separated paper tasks. Use video_qa for the documented Video QA "
+ "group or paper for the full suite."
+ ),
+ )
+ parser.add_argument(
+ "--dataset",
+ help=(
+ "Canonical local staging root or Hugging Face dataset repository id. "
+ "This is the preferred evaluation-data input."
+ ),
+ )
+ parser.add_argument(
+ "--revision",
+ default="main",
+ help="Hugging Face dataset revision (default: main).",
+ )
+ parser.add_argument(
+ "--splits",
+ help="Optional comma-separated canonical split ids to evaluate.",
+ )
+ parser.add_argument(
+ "--asset-root",
+ help=(
+ "Optional local root containing canonical media/ and artifacts/ "
+ "when JSONL metadata is stored separately."
+ ),
+ )
+ parser.add_argument("--cache-dir", help="Optional Hugging Face snapshot cache directory.")
+ parser.add_argument(
+ "--local-files-only",
+ action="store_true",
+ help="Resolve a Hugging Face dataset only from the local cache.",
+ )
+ parser.add_argument(
+ "--data-root",
+ help=(
+ "Legacy root containing one directory per task. Cannot be combined "
+ "with --dataset."
+ ),
+ )
+ parser.add_argument(
+ "--task-config",
+ action="append",
+ default=[],
+ metavar="TASK=FILE",
+ help="YAML/JSON environment mapping for one task; repeat as needed.",
+ )
+ parser.add_argument(
+ "--evaluator",
+ help="Path to eval/task/eval.sh (normally discovered from the source checkout).",
+ )
+ parser.add_argument("--gpus", default="0", help="Comma-separated local GPU ids.")
+ parser.add_argument("--tp-size", type=int, default=1, help="Tensor parallel size.")
+ parser.add_argument(
+ "--batch-size",
+ type=int,
+ help="Optional per-task batch-size override for this launch.",
+ )
+ parser.add_argument(
+ "--max-samples",
+ type=int,
+ help=(
+ "Optional sample limit for supported task smoke tests."
+ ),
+ )
+ parser.add_argument("--force-merge", action="store_true")
+ parser.add_argument("--skip-merge", action="store_true")
+ parser.add_argument("--merged-model")
+ parser.add_argument("--base-model")
+ parser.add_argument("--segmentation-run-sam2", action="store_true")
+ parser.add_argument(
+ "--summary",
+ default="orarl-eval-summary.json",
+ help="Aggregate JSON output path.",
+ )
+ parser.add_argument(
+ "--results-root",
+ help="Directory to scan for evaluator summary.json files.",
+ )
+ mode = parser.add_mutually_exclusive_group()
+ mode.add_argument("--dry-run", dest="dry_run", action="store_true")
+ mode.add_argument("--run", dest="dry_run", action="store_false")
+ parser.set_defaults(dry_run=True)
+ return parser
+
+
+def _existing(value: str, label: str, *, directory: bool | None = None) -> Path:
+ path = Path(value).expanduser().resolve()
+ if not path.exists():
+ raise CliError(f"{label} does not exist: {path}")
+ if directory is True and not path.is_dir():
+ raise CliError(f"{label} must be a directory: {path}")
+ if directory is False and not path.is_file():
+ raise CliError(f"{label} must be a file: {path}")
+ return path
+
+
+def _parse_tasks(value: str) -> list[str]:
+ requested = [item.strip().casefold() for item in value.split(",") if item.strip()]
+ if not requested:
+ raise CliError("--tasks cannot be empty")
+ expanded: list[str] = []
+ for task in requested:
+ values = (
+ PAPER_TASKS if task == "paper" else VIDEO_QA_TASKS if task == "video_qa" else (task,)
+ )
+ for value_item in values:
+ if value_item not in PAPER_TASKS:
+ raise CliError(
+ f"unsupported task {value_item!r}; choose from {', '.join(PAPER_TASKS)}"
+ )
+ if value_item not in expanded:
+ expanded.append(value_item)
+ return expanded
+
+
+def _installed_evaluator() -> Path | None:
+ try:
+ package_distribution = distribution("orarl")
+ except PackageNotFoundError:
+ return None
+
+ suffix = ("share", "orarl", "eval", "task", "eval.sh")
+ for entry in package_distribution.files or ():
+ parts = PurePosixPath(str(entry)).parts
+ if len(parts) < len(suffix) or tuple(parts[-len(suffix) :]) != suffix:
+ continue
+ candidate = Path(package_distribution.locate_file(entry)).resolve()
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def _discover_evaluator(explicit: str | None) -> Path:
+ candidates: list[Path] = []
+ if explicit:
+ candidate = Path(explicit).expanduser()
+ if not candidate.is_file():
+ raise CliError(f"evaluator does not exist or is not a file: {candidate}")
+ return candidate.resolve()
+ configured = os.environ.get("ORARL_EVALUATOR")
+ if configured:
+ candidate = Path(configured).expanduser()
+ if not candidate.is_file():
+ raise CliError(f"ORARL_EVALUATOR does not name an evaluator file: {candidate}")
+ return candidate.resolve()
+ runtime_root = os.environ.get("ORARL_RUNTIME_ROOT")
+ if runtime_root:
+ candidates.append(Path(runtime_root).expanduser() / "eval" / "task" / "eval.sh")
+
+ try:
+ runtime_spec = find_spec("verl")
+ except (ImportError, ModuleNotFoundError, ValueError):
+ runtime_spec = None
+ if runtime_spec is not None:
+ if runtime_spec.origin:
+ candidates.append(
+ Path(runtime_spec.origin).resolve().parent.parent / "eval" / "task" / "eval.sh"
+ )
+ for location in runtime_spec.submodule_search_locations or ():
+ candidates.append(Path(location).resolve().parent / "eval" / "task" / "eval.sh")
+ candidates.extend(
+ (
+ Path.cwd() / "eval" / "task" / "eval.sh",
+ Path(__file__).resolve().parents[2] / "eval" / "task" / "eval.sh",
+ Path(__file__).resolve().parents[3] / "eval" / "task" / "eval.sh",
+ )
+ )
+ installed_evaluator = _installed_evaluator()
+ if installed_evaluator is not None:
+ candidates.append(installed_evaluator)
+ for candidate in candidates:
+ if candidate.is_file():
+ return candidate.resolve()
+ raise CliError(
+ "eval/task/eval.sh was not found; pass --evaluator, set "
+ "ORARL_EVALUATOR, or set ORARL_RUNTIME_ROOT to a checkout that ships it"
+ )
+
+
+def _root_environment(root: Path, tasks: Sequence[str]) -> dict[str, str]:
+ environment: dict[str, str] = {}
+ for task in tasks:
+ task_root = root / task
+ if task == "videomme":
+ environment.update(
+ {
+ "VIDEOMME_VIDEO_BASE": str(task_root),
+ "VIDEOMME_VIDEO_DIR": str(task_root / "videos"),
+ "VIDEOMME_DATA_FILE": str(task_root / "annotations.jsonl"),
+ "VIDEOMME_PREPROCESSED_VIDEO_DIR": str(task_root / "preprocessed_videos"),
+ }
+ )
+ elif task == "videommev2":
+ environment.update(
+ {
+ "VIDEOMMEV2_VIDEO_BASE": str(task_root),
+ "VIDEOMMEV2_VIDEO_DIR": str(task_root / "videos"),
+ "VIDEOMMEV2_DATA_FILE": str(task_root / "annotations.jsonl"),
+ "VIDEOMMEV2_PREPROCESSED_VIDEO_DIR": str(task_root / "preprocessed_videos"),
+ }
+ )
+ elif task in {
+ "mvbench",
+ "mmvu",
+ "videoholmes",
+ "longvideobench",
+ "mlvu",
+ }:
+ prefix = _TASK_PREFIX[task]
+ environment[f"{prefix}_DATA_FILE"] = str(task_root / "annotations.jsonl")
+ environment[f"{prefix}_VIDEO_ROOT"] = str(task_root)
+ elif task == "vsi":
+ environment.update(
+ {
+ "VSI_DATA_FILE": str(task_root / "annotations.jsonl"),
+ "VSI_PREPROCESSED_VIDEO_DIR": str(task_root / "preprocessed_videos"),
+ }
+ )
+ elif task == "mmsi":
+ environment["MMSI_DATA_FILE"] = str(task_root / "MMSI_bench.tsv")
+ elif task == "mindcube":
+ environment.update(
+ {
+ "MINDCUBE_DATA_FILE": str(task_root / "combined-00000-of-00001.parquet"),
+ "MINDCUBE_EXPECTED_SAMPLES": "1050",
+ }
+ )
+ elif task == "revsi":
+ environment.update(
+ {
+ "REVSI_DATA_FILE": str(
+ task_root / "all_frame" / "test-00000-of-00001.parquet"
+ ),
+ "REVSI_VIDEO_ROOT": str(task_root / "all_frame"),
+ }
+ )
+ elif task == "spatial_grounding":
+ environment.update(
+ {
+ "SPATIAL_GROUNDING_BENCH_DIR": str(task_root),
+ "SPATIAL_GROUNDING_IMAGE_ROOT": str(task_root / "images"),
+ }
+ )
+ elif task == "tracking":
+ environment.update(
+ {
+ "TRACKING_BENCH_DIR": str(task_root),
+ "TRACKING_BASE_PREFIX": str(task_root),
+ }
+ )
+ elif task == "stvg":
+ environment.update(
+ {
+ "STVG_BENCH_DIR": str(task_root),
+ "STVG_BASE_PREFIX": str(task_root),
+ }
+ )
+ elif task == "temporal_grounding":
+ environment["TIMELENS_BENCH_DIR"] = str(task_root)
+ elif task == "segmentation":
+ environment.update(
+ {
+ "SEGMENTATION_BENCH_DIR": str(task_root),
+ "SEGMENTATION_DATA_ROOT": str(task_root),
+ }
+ )
+ return environment
+
+
+def _stringify_env(value: Any) -> str:
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, list):
+ return ",".join(str(item) for item in value)
+ if value is None:
+ return ""
+ return str(value)
+
+
+def _download_dataset_snapshot(
+ repo_id: str,
+ namespace: argparse.Namespace,
+ allow_patterns: Sequence[str],
+) -> Path:
+ try:
+ from huggingface_hub import snapshot_download
+ except ImportError as error:
+ raise CliError(
+ "resolving a Hugging Face --dataset requires huggingface_hub"
+ ) from error
+
+ cache_dir = (
+ str(Path(namespace.cache_dir).expanduser())
+ if namespace.cache_dir
+ else None
+ )
+ try:
+ snapshot = snapshot_download(
+ repo_id=repo_id,
+ repo_type="dataset",
+ revision=namespace.revision,
+ cache_dir=cache_dir,
+ local_files_only=namespace.local_files_only,
+ allow_patterns=list(allow_patterns),
+ )
+ except Exception as error:
+ raise CliError(f"cannot resolve Hugging Face dataset {repo_id!r}: {error}") from error
+ return _existing(snapshot, "downloaded dataset root", directory=True)
+
+
+def _load_canonical_manifests(
+ root: Path,
+ asset_root: Path | None = None,
+) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]]]:
+ assets_root = asset_root or root
+ required = {
+ "datasets.jsonl": root / "datasets.jsonl",
+ "assets.jsonl": assets_root / "assets.jsonl",
+ }
+ missing = [name for name, path in required.items() if not path.is_file()]
+ if missing:
+ raise CliError(
+ f"canonical dataset root {root} is missing: {', '.join(missing)}"
+ )
+ try:
+ datasets = load_dataset_manifest(root)
+ assets = load_asset_manifest(assets_root)
+ except (ManifestError, StagingError, OSError) as error:
+ raise CliError(f"invalid canonical dataset root {root}: {error}") from error
+ if not datasets:
+ raise CliError(f"canonical dataset manifest is empty: {root / 'datasets.jsonl'}")
+ return list(datasets), list(assets)
+
+
+def _select_dataset_records(
+ records: Sequence[Mapping[str, Any]],
+ tasks: Sequence[str],
+ splits: set[str] | None = None,
+) -> list[Mapping[str, Any]]:
+ if splits is not None:
+ available_splits = {
+ str(record["split"])
+ for record in records
+ if str(record["task"]) in tasks
+ }
+ missing_splits = sorted(splits - available_splits)
+ if missing_splits:
+ raise CliError(
+ "canonical dataset has no requested split(s): "
+ + ", ".join(missing_splits)
+ )
+ selected = [
+ record
+ for record in records
+ if str(record["task"]) in tasks
+ and (splits is None or str(record["split"]) in splits)
+ ]
+ covered = {str(record["task"]) for record in selected}
+ missing = sorted(set(tasks) - covered)
+ if missing:
+ raise CliError(
+ "canonical dataset has no configuration record for: "
+ + ", ".join(missing)
+ )
+ return selected
+
+
+def _selected_asset_records(
+ assets: Sequence[Mapping[str, Any]],
+ records: Sequence[Mapping[str, Any]],
+) -> list[Mapping[str, Any]]:
+ annotation_paths = {str(record["annotation_path"]) for record in records}
+ declared_roots = {
+ str(path).rstrip("/")
+ for record in records
+ for field in ("media_paths", "artifact_paths")
+ for path in record.get(field, ())
+ }
+
+ def selected(path: str) -> bool:
+ return path in annotation_paths or any(
+ path == root or path.startswith(f"{root}/")
+ for root in declared_roots
+ )
+
+ return [
+ asset
+ for asset in assets
+ if selected(str(asset["path"]))
+ ]
+
+
+def _verify_runtime_assets(
+ root: Path,
+ records: Sequence[Mapping[str, Any]],
+ assets: Sequence[Mapping[str, Any]],
+ asset_root: Path | None = None,
+) -> None:
+ selected_assets = _selected_asset_records(assets, records)
+ manifested = {str(asset["path"]) for asset in selected_assets}
+ annotations = {str(record["annotation_path"]) for record in records}
+ missing_annotations = sorted(annotations - manifested)
+ if missing_annotations:
+ raise CliError(
+ "canonical annotations are missing from assets.jsonl: "
+ + ", ".join(missing_annotations)
+ )
+
+ try:
+ if asset_root is None:
+ validate_asset_manifest(
+ selected_assets,
+ repository_root=root,
+ context=str(root / "assets.jsonl"),
+ )
+ validate_dataset_manifest(
+ records,
+ repository_root=root,
+ context=str(root / "datasets.jsonl"),
+ )
+ else:
+ annotation_assets = [
+ asset
+ for asset in selected_assets
+ if str(asset["path"]).startswith("annotations/")
+ ]
+ media_assets = [
+ asset
+ for asset in selected_assets
+ if not str(asset["path"]).startswith("annotations/")
+ ]
+ validate_asset_manifest(
+ annotation_assets,
+ repository_root=root,
+ context=str(root / "assets.jsonl"),
+ )
+ validate_asset_manifest(
+ media_assets,
+ repository_root=asset_root,
+ context=str(root / "assets.jsonl"),
+ )
+ validate_dataset_manifest(
+ records,
+ context=str(root / "datasets.jsonl"),
+ )
+ for record in records:
+ annotation = root / str(record["annotation_path"])
+ rows = load_evaluation_jsonl(
+ annotation,
+ benchmark=str(record["benchmark"]),
+ split=str(record["split"]),
+ eval_task=str(record["task"]),
+ repository_root=root if asset_root is None else None,
+ )
+ expected = int(record["expected_count"])
+ if len(rows) != expected:
+ raise CliError(
+ f"{record['benchmark']}/{record['split']}: expected "
+ f"{expected} rows, found {len(rows)}"
+ )
+ unmanifested = sorted(
+ {
+ path
+ for row in rows
+ for path in evaluation_asset_paths(row)
+ if path not in manifested
+ }
+ )
+ if unmanifested:
+ raise CliError(
+ f"{record['benchmark']}/{record['split']} references "
+ "unmanifested assets: "
+ + ", ".join(unmanifested[:5])
+ )
+ except (EvaluationSchemaError, ManifestError, StagingError, OSError) as error:
+ raise CliError(f"canonical dataset validation failed: {error}") from error
+
+
+def _resolve_canonical_dataset(
+ namespace: argparse.Namespace,
+ tasks: Sequence[str],
+ asset_root: Path | None = None,
+) -> tuple[Path, list[Mapping[str, Any]]]:
+ raw_dataset = str(namespace.dataset).strip()
+ if not raw_dataset:
+ raise CliError("--dataset cannot be empty")
+
+ local = Path(raw_dataset).expanduser()
+ remote = not local.exists()
+ if remote and (
+ local.is_absolute()
+ or raw_dataset.startswith((".", "~"))
+ or raw_dataset.endswith(("/", os.sep))
+ ):
+ raise CliError(f"local canonical dataset root does not exist: {local}")
+
+ root = (
+ _download_dataset_snapshot(
+ raw_dataset,
+ namespace,
+ _INDEX_MANIFEST_FILES if asset_root is not None else _MANIFEST_FILES,
+ )
+ if remote
+ else _existing(raw_dataset, "canonical dataset root", directory=True)
+ )
+ requested_splits = (
+ {
+ value.strip().casefold().replace("-", "_")
+ for value in str(namespace.splits).split(",")
+ if value.strip()
+ }
+ if namespace.splits
+ else None
+ )
+ if requested_splits == set():
+ raise CliError("--splits cannot be empty")
+ all_records, all_assets = _load_canonical_manifests(root, asset_root)
+ selected = _select_dataset_records(all_records, tasks, requested_splits)
+
+ if remote and not namespace.dry_run:
+ selected_assets = _selected_asset_records(all_assets, selected)
+ if asset_root is not None:
+ selected_assets = [
+ asset
+ for asset in selected_assets
+ if str(asset["path"]).startswith("annotations/")
+ ]
+ allow_patterns = [
+ *(_INDEX_MANIFEST_FILES if asset_root is not None else _MANIFEST_FILES),
+ *(str(asset["path"]) for asset in selected_assets),
+ ]
+ root = _download_dataset_snapshot(
+ raw_dataset,
+ namespace,
+ tuple(dict.fromkeys(allow_patterns)),
+ )
+ all_records, all_assets = _load_canonical_manifests(root, asset_root)
+ selected = _select_dataset_records(all_records, tasks, requested_splits)
+
+ if not namespace.dry_run:
+ _verify_runtime_assets(root, selected, all_assets, asset_root)
+ return root, selected
+
+
+def _resolve_profile_path(
+ root: Path,
+ value: str,
+ name: str,
+ asset_root: Path | None = None,
+) -> str:
+ relative = Path(value)
+ selected_root = (
+ asset_root
+ if asset_root is not None
+ and relative.parts
+ and relative.parts[0].casefold() in {"artifacts", "media"}
+ else root
+ )
+ path = (selected_root / relative).resolve()
+ try:
+ path.relative_to(selected_root)
+ except ValueError as error:
+ raise CliError(
+ f"canonical profile path escapes dataset root: {name}={value}"
+ ) from error
+ return str(path)
+
+
+def _merge_profile_value(
+ environment: dict[str, str],
+ *,
+ task: str,
+ name: str,
+ value: str,
+) -> None:
+ previous = environment.get(name)
+ if previous is not None and previous != value:
+ raise CliError(
+ f"conflicting canonical profiles for {task}: "
+ f"{name} is both {previous!r} and {value!r}"
+ )
+ environment[name] = value
+
+
+def _validate_task_profiles(
+ task: str,
+ records: Sequence[Mapping[str, Any]],
+) -> None:
+ splits: set[str] = set()
+ baseline: dict[str, Any] | None = None
+ for record in records:
+ split = str(record["split"])
+ if split in splits:
+ raise CliError(
+ f"duplicate canonical profile split for {task}: {split}"
+ )
+ splits.add(split)
+
+ profile = {
+ "family": record.get("family"),
+ "evaluation": record.get("evaluation"),
+ }
+ if task != "temporal_grounding":
+ profile["preprocessing"] = record.get("preprocessing")
+ if baseline is None:
+ baseline = profile
+ elif profile != baseline:
+ raise CliError(
+ f"conflicting canonical evaluation profiles for task {task}"
+ )
+
+ if task not in _STRUCTURED_TASKS and len(records) > 1:
+ raise CliError(
+ f"duplicate canonical profiles for non-structured task {task}"
+ )
+
+
+def _canonical_task_defaults(
+ root: Path,
+ task: str,
+ records: Sequence[Mapping[str, Any]],
+ asset_root: Path | None = None,
+) -> dict[str, str]:
+ annotations = [
+ (root / str(record["annotation_path"])).resolve() for record in records
+ ]
+ prefix = _TASK_PREFIX[task]
+ defaults: dict[str, str] = {}
+ runtime_asset_root = asset_root or root
+
+ if task in _STRUCTURED_TASKS:
+ parents = {path.parent for path in annotations}
+ if len(parents) != 1:
+ raise CliError(
+ f"{task} canonical annotations must share one benchmark directory; "
+ "use benchmark equal to eval_task and splits for sub-benchmarks"
+ )
+ bench_dir = str(next(iter(parents)))
+ defaults[f"{prefix}_BENCH_DIR"] = bench_dir
+ defaults[f"{prefix}_DATASETS"] = ",".join(
+ _evaluator_split_name(task, record["split"])
+ for record in records
+ )
+ if task == "spatial_grounding":
+ defaults["SPATIAL_GROUNDING_IMAGE_ROOT"] = str(runtime_asset_root)
+ elif task == "tracking":
+ defaults["TRACKING_BASE_PREFIX"] = str(runtime_asset_root)
+ elif task == "stvg":
+ defaults["STVG_BASE_PREFIX"] = str(runtime_asset_root)
+ elif task == "segmentation":
+ defaults["SEGMENTATION_DATA_ROOT"] = str(runtime_asset_root)
+ return defaults
+
+ annotation = str(annotations[0])
+ defaults[f"{prefix}_DATA_FILE"] = annotation
+ if task in {"videomme", "videommev2"}:
+ defaults[f"{prefix}_VIDEO_BASE"] = str(runtime_asset_root)
+ defaults[f"{prefix}_VIDEO_DIR"] = str(runtime_asset_root)
+ defaults[f"{prefix}_PREPROCESSED_VIDEO_DIR"] = str(runtime_asset_root)
+ elif task in {
+ "mvbench",
+ "mmvu",
+ "videoholmes",
+ "longvideobench",
+ "mlvu",
+ }:
+ defaults[f"{prefix}_VIDEO_ROOT"] = str(runtime_asset_root)
+ if task == "longvideobench":
+ defaults["LONGVIDEOBENCH_SUBTITLE_ROOT"] = str(runtime_asset_root)
+ elif task == "vsi":
+ defaults["VSI_PREPROCESSED_VIDEO_DIR"] = str(runtime_asset_root)
+ defaults["VSI_EXPECTED_SAMPLES"] = str(records[0]["expected_count"])
+ elif task == "mmsi":
+ defaults["MMSI_EXPECTED_SAMPLES"] = str(records[0]["expected_count"])
+ elif task == "mindcube":
+ defaults["MINDCUBE_EXPECTED_SAMPLES"] = str(records[0]["expected_count"])
+ elif task == "revsi":
+ defaults["REVSI_VIDEO_ROOT"] = str(runtime_asset_root)
+ defaults["REVSI_EXPECTED_SAMPLES"] = str(records[0]["expected_count"])
+ return defaults
+
+
+def _canonical_environment(
+ root: Path,
+ records: Sequence[Mapping[str, Any]],
+ tasks: Sequence[str],
+ asset_root: Path | None = None,
+) -> dict[str, str]:
+ environment = {
+ "ORARL_EVAL_DATA_ROOT": str(root),
+ "ORARL_EVAL_DATASETS_JSONL": str(root / "datasets.jsonl"),
+ }
+ if asset_root is not None:
+ environment["ORARL_EVAL_ASSET_ROOT"] = str(asset_root)
+ for task in tasks:
+ task_records = [record for record in records if record["task"] == task]
+ _validate_task_profiles(task, task_records)
+ prefix = _TASK_PREFIX[task] + "_"
+ for record in task_records:
+ values = record.get("legacy_environment", {})
+ if not isinstance(values, Mapping):
+ continue
+ for raw_name, raw_value in values.items():
+ name = str(raw_name).strip().upper()
+ if not name.startswith(prefix) and name != "EVAL_CONDA_ENV":
+ raise CliError(
+ f"unrelated canonical profile setting for {task}: {name}"
+ )
+ if _SENSITIVE_NAME.search(name):
+ raise CliError("canonical profiles cannot contain sensitive settings")
+ value = _stringify_env(raw_value)
+ if _URI_USERINFO.search(value):
+ raise CliError("canonical profiles cannot contain credential-bearing URLs")
+ if name.endswith(_PATH_ENV_SUFFIXES) and value:
+ value = _resolve_profile_path(root, value, name, asset_root)
+ _merge_profile_value(
+ environment,
+ task=task,
+ name=name,
+ value=value,
+ )
+
+ for name, value in _canonical_task_defaults(
+ root,
+ task,
+ task_records,
+ asset_root,
+ ).items():
+ if name.endswith("_DATASETS"):
+ selected = value.split(",")
+ previous = environment.get(name, "").split(",")
+ ordered = [
+ item
+ for prior in previous
+ for item in selected
+ if prior.strip().casefold().replace("-", "_")
+ == item.casefold().replace("-", "_")
+ ]
+ environment[name] = ",".join(
+ dict.fromkeys([*ordered, *selected])
+ )
+ else:
+ environment.setdefault(name, value)
+ if task == "temporal_grounding":
+ for field, name in {
+ "fps": "TIMELENS_FPS",
+ "min_tokens": "TIMELENS_MIN_TOKENS",
+ "max_frames": "TIMELENS_MAX_FRAMES",
+ "max_pixels": "TIMELENS_MAX_PIXELS",
+ "total_tokens": "TIMELENS_TOTAL_TOKENS",
+ }.items():
+ values = {
+ str(preprocessing[field])
+ for record in task_records
+ if isinstance(
+ preprocessing := record.get("preprocessing"),
+ Mapping,
+ )
+ and field in preprocessing
+ }
+ if len(values) == 1:
+ environment[name] = values.pop()
+ return environment
+
+
+def _task_configs(
+ specifications: Sequence[str],
+ tasks: Sequence[str],
+ data_root: Path | None,
+) -> tuple[dict[str, str], set[str]]:
+ environment: dict[str, str] = {}
+ configured_tasks: set[str] = set()
+ for specification in specifications:
+ task, separator, raw_path = specification.partition("=")
+ task = task.strip().casefold()
+ if not separator or task not in PAPER_TASKS:
+ raise CliError(f"--task-config expects TASK=FILE for a paper task: {specification!r}")
+ if task not in tasks:
+ raise CliError(f"task config supplied for unrequested task: {task}")
+ config_path = _existing(raw_path, f"{task} config", directory=False)
+ try:
+ payload = yaml.safe_load(config_path.read_text(encoding="utf-8"))
+ except (OSError, yaml.YAMLError) as error:
+ raise CliError(f"cannot load {config_path}: {error}") from error
+ if not isinstance(payload, Mapping):
+ raise CliError(f"task config must be a mapping: {config_path}")
+ declared_task = payload.get("task")
+ if declared_task is not None and str(declared_task).casefold() != task:
+ raise CliError(f"{config_path} declares task {declared_task!r}, expected {task!r}")
+ values = payload.get("environment", payload.get("env", payload))
+ if not isinstance(values, Mapping):
+ raise CliError(f"{config_path}: environment must be a mapping")
+ prefix = _TASK_PREFIX[task] + "_"
+ for raw_name, raw_value in values.items():
+ name = str(raw_name).strip().upper()
+ if name == "TASK":
+ continue
+ if not name.startswith(prefix) and name != "EVAL_CONDA_ENV":
+ raise CliError(f"{config_path}: {name} is not allowlisted for {task}")
+ if _SENSITIVE_NAME.search(name):
+ raise CliError(f"{config_path}: sensitive environment names are not accepted")
+ value = _stringify_env(raw_value)
+ if _URI_USERINFO.search(value):
+ raise CliError(f"{config_path}: credential-bearing URLs are not accepted")
+ if name.endswith(_PATH_ENV_SUFFIXES) and value and not Path(value).is_absolute():
+ base = data_root if data_root is not None else config_path.parent
+ value = str((base / value).resolve())
+ environment[name] = value
+ configured_tasks.add(task)
+ return environment, configured_tasks
+
+
+def build_launch(
+ namespace: argparse.Namespace,
+) -> tuple[list[str], dict[str, str], list[str], Path, Path]:
+ model = _existing(namespace.model, "model", directory=True)
+ tasks = _parse_tasks(namespace.tasks)
+ evaluator = _discover_evaluator(namespace.evaluator)
+ if not re.fullmatch(r"\d+(?:,\d+)*", namespace.gpus):
+ raise CliError("--gpus must be a comma-separated list of non-negative ids")
+ if namespace.tp_size <= 0:
+ raise CliError("--tp-size must be positive")
+ if namespace.batch_size is not None and namespace.batch_size <= 0:
+ raise CliError("--batch-size must be positive")
+ if namespace.max_samples is not None and namespace.max_samples <= 0:
+ raise CliError("--max-samples must be positive")
+ if namespace.force_merge and namespace.skip_merge:
+ raise CliError("--force-merge and --skip-merge are mutually exclusive")
+ if namespace.dataset and namespace.data_root:
+ raise CliError("--dataset and legacy --data-root are mutually exclusive")
+ if namespace.asset_root and not namespace.dataset:
+ raise CliError("--asset-root requires --dataset")
+ if namespace.splits and not namespace.dataset:
+ raise CliError("--splits requires --dataset")
+
+ canonical_root: Path | None = None
+ canonical_records: list[Mapping[str, Any]] = []
+ asset_root = (
+ _existing(namespace.asset_root, "asset root", directory=True)
+ if namespace.asset_root
+ else None
+ )
+ if namespace.dataset:
+ canonical_root, canonical_records = _resolve_canonical_dataset(
+ namespace,
+ tasks,
+ asset_root,
+ )
+ environment = _canonical_environment(
+ canonical_root,
+ canonical_records,
+ tasks,
+ asset_root,
+ )
+ else:
+ environment = {}
+
+ data_root = None
+ if namespace.data_root:
+ data_root = _existing(namespace.data_root, "data root", directory=True)
+ environment.update(_root_environment(data_root, tasks))
+ config_root = canonical_root if canonical_root is not None else data_root
+ configured_environment, configured_tasks = _task_configs(
+ namespace.task_config, tasks, config_root
+ )
+ environment.update(configured_environment)
+ if canonical_root is not None:
+ configured_tasks.update(tasks)
+ if namespace.batch_size is not None:
+ for task in tasks:
+ environment[f"{_TASK_PREFIX[task]}_BATCH_SIZE"] = str(namespace.batch_size)
+ if namespace.max_samples is not None:
+ unsupported = set(tasks) - {
+ *VIDEO_QA_TASKS,
+ "temporal_grounding",
+ "spatial_grounding",
+ "tracking",
+ "stvg",
+ "revsi",
+ "segmentation",
+ }
+ if unsupported:
+ raise CliError(
+ "--max-samples only supports video QA, temporal_grounding, "
+ "spatial_grounding, tracking, stvg, revsi, or segmentation"
+ )
+ if "temporal_grounding" in tasks:
+ environment["TIMELENS_MAX_SAMPLES"] = str(namespace.max_samples)
+ if "spatial_grounding" in tasks:
+ environment["SPATIAL_GROUNDING_MAX_SAMPLES"] = str(
+ namespace.max_samples
+ )
+ if "tracking" in tasks:
+ environment["TRACKING_MAX_SAMPLES"] = str(namespace.max_samples)
+ if "stvg" in tasks:
+ environment["STVG_MAX_SAMPLES"] = str(namespace.max_samples)
+ if "revsi" in tasks:
+ environment["REVSI_MAX_SAMPLES"] = str(namespace.max_samples)
+ if "segmentation" in tasks:
+ environment["SEGMENTATION_MAX_SAMPLES"] = str(namespace.max_samples)
+ for task in tasks:
+ if task in VIDEO_QA_TASKS:
+ environment[f"{_TASK_PREFIX[task]}_MAX_SAMPLES"] = str(
+ namespace.max_samples
+ )
+ eval_conda_env = environment.pop("EVAL_CONDA_ENV", "").strip()
+ if not namespace.dry_run:
+ missing_configs = (
+ sorted(set(tasks) - configured_tasks)
+ if data_root is None and canonical_root is None
+ else []
+ )
+ if missing_configs:
+ raise CliError(
+ "--run requires --dataset, --data-root, or one --task-config per task; "
+ f"missing: {', '.join(missing_configs)}"
+ )
+ for task in tasks:
+ missing_inputs = sorted(_REQUIRED_INPUTS[task] - environment.keys())
+ if missing_inputs:
+ raise CliError(
+ f"{task} is missing explicit input settings: " + ", ".join(missing_inputs)
+ )
+ for name in sorted(_REQUIRED_INPUTS[task]):
+ path = Path(environment[name])
+ expected_file = name.endswith("_FILE")
+ if not path.exists():
+ raise CliError(f"{task} input does not exist: {name}={path}")
+ if expected_file and not path.is_file():
+ raise CliError(f"{task} input must be a file: {name}={path}")
+ if not expected_file and not path.is_dir():
+ raise CliError(f"{task} input must be a directory: {name}={path}")
+ if namespace.segmentation_run_sam2:
+ postprocessor_inputs = {
+ "SEGMENTATION_POSTPROCESSOR_PATH",
+ "SEGMENTATION_SAM2_CFG",
+ "SEGMENTATION_SAM2_CKPT",
+ }
+ missing_postprocessor = sorted(postprocessor_inputs - environment.keys())
+ if missing_postprocessor:
+ raise CliError(
+ "segmentation post-processing requires explicit settings: "
+ + ", ".join(missing_postprocessor)
+ )
+ for name in sorted(postprocessor_inputs):
+ path = Path(environment[name])
+ if not path.is_file():
+ raise CliError(
+ f"segmentation post-processing input must be a file: {name}={path}"
+ )
+
+ command = [
+ "bash",
+ str(evaluator),
+ "--model",
+ str(model),
+ "--tasks",
+ ",".join(tasks),
+ "--gpus",
+ namespace.gpus,
+ "--tp-size",
+ str(namespace.tp_size),
+ ]
+ if namespace.force_merge:
+ command.append("--force-merge")
+ if namespace.skip_merge:
+ command.append("--skip-merge")
+ if namespace.merged_model:
+ command.extend(("--merged-model", str(Path(namespace.merged_model).expanduser().resolve())))
+ if namespace.base_model:
+ command.extend(("--base-model", str(_existing(namespace.base_model, "base model"))))
+ if namespace.segmentation_run_sam2:
+ command.append("--segmentation-run-sam2")
+ if eval_conda_env:
+ command.extend(("--env", eval_conda_env))
+
+ project_root = evaluator.parents[2]
+ results_root = (
+ Path(namespace.results_root).expanduser().resolve()
+ if namespace.results_root
+ else project_root / "outputs"
+ )
+ summary_path = Path(namespace.summary).expanduser().resolve()
+ return command, environment, tasks, results_root, summary_path
+
+
+def _execution_environment(configured: Mapping[str, str]) -> dict[str, str]:
+ """Build an evaluator environment without inherited task-path state."""
+
+ environment = os.environ.copy()
+ task_prefixes = tuple(prefix + "_" for prefix in set(_TASK_PREFIX.values()))
+ for name in tuple(environment):
+ if (
+ name.startswith(task_prefixes)
+ or name == "EVAL_CONDA_ENV"
+ or name.startswith("ORARL_EVAL_")
+ ):
+ environment.pop(name, None)
+ environment.update(configured)
+ return environment
+
+
+def _summary_state(root: Path) -> dict[Path, int]:
+ if not root.is_dir():
+ return {}
+ return {path: path.stat().st_mtime_ns for path in root.rglob("summary.json") if path.is_file()}
+
+
+def _task_for_summary(path: Path, tasks: Sequence[str]) -> str | None:
+ lowered_parts = {part.casefold() for part in path.parts}
+ for task in tasks:
+ if task in lowered_parts:
+ return task
+ return None
+
+
+def _write_aggregate(
+ destination: Path,
+ *,
+ model: str,
+ tasks: Sequence[str],
+ results_root: Path,
+ before: Mapping[Path, int],
+ returncode: int,
+) -> None:
+ after = _summary_state(results_root)
+ changed = [
+ path for path, modified in after.items() if path not in before or before[path] != modified
+ ]
+ records: list[dict[str, Any]] = []
+ observed: set[str] = set()
+ for path in sorted(changed):
+ task = _task_for_summary(path, tasks)
+ if task is not None:
+ observed.add(task)
+ try:
+ metrics: Any = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ metrics = {"error": str(error)}
+ try:
+ display_path = str(path.relative_to(results_root))
+ except ValueError:
+ display_path = str(path)
+ records.append({"task": task, "summary": display_path, "metrics": metrics})
+
+ payload = {
+ "schema_version": 1,
+ "created_at": datetime.now(timezone.utc).isoformat(),
+ "model": model,
+ "requested_tasks": list(tasks),
+ "completed_tasks": sorted(observed),
+ "missing_tasks": sorted(set(tasks) - observed),
+ "evaluator_returncode": returncode,
+ "results": records,
+ }
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = create_parser()
+ namespace = parser.parse_args(argv)
+ try:
+ command, configured_env, tasks, results_root, summary_path = build_launch(namespace)
+ except CliError as error:
+ parser.error(str(error))
+
+ print(f"mode={'dry-run' if namespace.dry_run else 'run'} tasks={','.join(tasks)}")
+ if configured_env:
+ print("configured environment: " + ", ".join(sorted(configured_env)))
+ print(shlex.join(command))
+ if namespace.dry_run:
+ print(f"aggregate summary would be written to {summary_path}")
+ return 0
+
+ before = _summary_state(results_root)
+ environment = _execution_environment(configured_env)
+ completed = subprocess.run(command, env=environment, check=False)
+ _write_aggregate(
+ summary_path,
+ model=str(Path(namespace.model).expanduser().resolve()),
+ tasks=tasks,
+ results_root=results_root,
+ before=before,
+ returncode=completed.returncode,
+ )
+ print(f"aggregate summary: {summary_path}")
+ return completed.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/code/orarl/cli/prepare.py b/code/orarl/cli/prepare.py
new file mode 100644
index 0000000000000000000000000000000000000000..b07ed4b4b114b34bc1dd1b09217a00753d1904e4
--- /dev/null
+++ b/code/orarl/cli/prepare.py
@@ -0,0 +1,18 @@
+"""Console entry point for the portable data builder."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Load the data builder lazily so CLI help stays lightweight."""
+
+ from orarl.data.build import main as build_main
+
+ result = build_main(argv)
+ return int(result) if result is not None else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/code/orarl/cli/train.py b/code/orarl/cli/train.py
new file mode 100644
index 0000000000000000000000000000000000000000..d4b2912148b8ec72b65b59632ddb587c10e9a73b
--- /dev/null
+++ b/code/orarl/cli/train.py
@@ -0,0 +1,365 @@
+"""Validated, temp-file-free launcher for OraRL training."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import shlex
+import shutil
+import subprocess
+import sys
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any, Sequence
+
+import yaml
+
+from orarl.resources import config_path
+
+_PROTECTED_OVERRIDES = {
+ "config",
+ "data.train_files",
+ "data.val_files",
+ "trainer.n_gpus_per_node",
+ "trainer.nnodes",
+ "trainer.save_checkpoint_path",
+ "worker.actor.model.model_path",
+ "worker.actor.model.tokenizer_path",
+}
+_SENSITIVE_OVERRIDE = re.compile(
+ r"(?:^|[._-])(?:api[_-]?key|auth[_-]?token|access[_-]?token|"
+ r"credential|password|private[_-]?key|secret)(?:$|[._-])",
+ flags=re.IGNORECASE,
+)
+_URI_USERINFO = re.compile(r"://[^/\s:@]+:[^@\s/]+@")
+_OC_ENV = re.compile(r"^\$\{oc\.env:([A-Za-z_][A-Za-z0-9_]*)(?:,(.*))?\}$")
+
+
+class CliError(ValueError):
+ """Raised when a launch request is incomplete or unsafe."""
+
+
+def create_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="orarl-train",
+ description=(
+ "Validate an OraRL config and launch the local-compatible verl runtime. "
+ "The default is a non-mutating dry run."
+ ),
+ )
+ parser.add_argument(
+ "--config",
+ required=True,
+ help=(
+ "Path to a complete YAML config, or one of the bundled names "
+ "(for example, orarl_4b.yaml)."
+ ),
+ )
+ parser.add_argument("--model", required=True, help="Local model or checkpoint path.")
+ parser.add_argument("--train-data", required=True, help="Training JSONL path.")
+ parser.add_argument("--val-data", required=True, help="Validation/canary JSONL path.")
+ parser.add_argument("--output", required=True, help="Checkpoint output directory.")
+ parser.add_argument("--nodes", type=int, default=1, help="Number of training nodes.")
+ parser.add_argument(
+ "--gpus",
+ "--gpus-per-node",
+ dest="gpus_per_node",
+ type=int,
+ default=8,
+ help="GPUs allocated on each node.",
+ )
+ parser.add_argument(
+ "--python",
+ default=sys.executable,
+ help="Python executable for the trainer (default: this interpreter).",
+ )
+ parser.add_argument(
+ "--set",
+ dest="overrides",
+ action="append",
+ default=[],
+ metavar="KEY=VALUE",
+ help="Additional OmegaConf override; repeat as needed.",
+ )
+ mode = parser.add_mutually_exclusive_group()
+ mode.add_argument(
+ "--dry-run",
+ dest="dry_run",
+ action="store_true",
+ help="Validate and print the command without executing it (default).",
+ )
+ mode.add_argument(
+ "--run",
+ dest="dry_run",
+ action="store_false",
+ help="Execute the validated command.",
+ )
+ parser.set_defaults(dry_run=True)
+ return parser
+
+
+def _existing_path(value: str, label: str, *, kind: str) -> Path:
+ path = Path(value).expanduser().resolve()
+ if not path.exists():
+ raise CliError(f"{label} does not exist: {path}")
+ if kind == "file" and not path.is_file():
+ raise CliError(f"{label} must be a file: {path}")
+ if kind == "directory" and not path.is_dir():
+ raise CliError(f"{label} must be a directory: {path}")
+ return path
+
+
+def _config_file(value: str) -> Path:
+ path = Path(value).expanduser()
+ if path.exists() or path.name != value:
+ return _existing_path(value, "config", kind="file")
+ try:
+ return config_path(value)
+ except (FileNotFoundError, ValueError) as error:
+ raise CliError(f"config does not exist and is not a bundled recipe: {value}") from error
+
+
+def _output_path(value: str) -> Path:
+ path = Path(value).expanduser().resolve()
+ if path.exists() and not path.is_dir():
+ raise CliError(f"output must be a directory path: {path}")
+ ancestor = path
+ while not ancestor.exists() and ancestor != ancestor.parent:
+ ancestor = ancestor.parent
+ if not ancestor.is_dir():
+ raise CliError(f"output has no usable parent directory: {path}")
+ if not os.access(ancestor, os.W_OK):
+ raise CliError(f"output parent is not writable: {ancestor}")
+ return path
+
+
+def _python_executable(value: str) -> str:
+ candidate = shutil.which(value)
+ if candidate is None:
+ path = Path(value).expanduser()
+ if path.is_file() and os.access(path, os.X_OK):
+ candidate = str(path.resolve())
+ if candidate is None:
+ raise CliError(f"Python executable is unavailable: {value}")
+ return candidate
+
+
+def _load_config(config: Path) -> dict[str, Any]:
+ try:
+ payload = yaml.safe_load(config.read_text(encoding="utf-8"))
+ except (OSError, yaml.YAMLError) as error:
+ raise CliError(f"cannot load config {config}: {error}") from error
+ if not isinstance(payload, dict):
+ raise CliError(f"config must contain a YAML mapping: {config}")
+ return payload
+
+
+def _algorithm_name(payload: Mapping[str, Any]) -> str:
+ algorithm = payload.get("algorithm")
+ if not isinstance(algorithm, dict):
+ raise CliError("config must define an algorithm mapping")
+ name = str(algorithm.get("name", "")).strip().casefold()
+ if name not in {"grpo", "orarl"}:
+ raise CliError("algorithm.name must be either 'grpo' or 'orarl'")
+ return name
+
+
+def _nested_value(payload: Mapping[str, Any], key: str) -> Any:
+ current: Any = payload
+ for part in key.split("."):
+ if not isinstance(current, Mapping) or part not in current:
+ raise CliError(f"config must define {key}")
+ current = current[part]
+ return current
+
+
+def _resolved_scalar(value: Any, key: str) -> Any:
+ if not isinstance(value, str):
+ return value
+ match = _OC_ENV.fullmatch(value.strip())
+ if match is None:
+ return value
+ environment_name, fallback = match.groups()
+ resolved = os.environ.get(environment_name, fallback)
+ if resolved is None:
+ raise CliError(f"{key} requires environment variable {environment_name}")
+ return resolved
+
+
+def _config_number(
+ payload: Mapping[str, Any],
+ overrides: Mapping[str, str],
+ key: str,
+ cast: type[int] | type[float],
+) -> int | float:
+ raw_value: Any = overrides[key] if key in overrides else _nested_value(payload, key)
+ raw_value = _resolved_scalar(raw_value, key)
+ if isinstance(raw_value, bool):
+ raise CliError(f"{key} must be a {cast.__name__}")
+ try:
+ return cast(raw_value)
+ except (TypeError, ValueError) as error:
+ raise CliError(f"{key} must resolve to a {cast.__name__}, got {raw_value!r}") from error
+
+
+def _validate_orarl_batch_layout(
+ payload: Mapping[str, Any],
+ overrides: Mapping[str, str],
+ *,
+ nodes: int,
+ gpus_per_node: int,
+) -> None:
+ rollout_batch_size = int(
+ _config_number(
+ payload,
+ overrides,
+ "data.rollout_batch_size",
+ int,
+ )
+ )
+ n_rollouts = int(_config_number(payload, overrides, "worker.rollout.n", int))
+ prune_ratio = float(
+ _config_number(
+ payload,
+ overrides,
+ "algorithm.selection_prune_ratio",
+ float,
+ )
+ )
+ positive_quota = int(
+ _config_number(
+ payload,
+ overrides,
+ "algorithm.selection_positive_quota",
+ int,
+ )
+ )
+ negative_quota = int(
+ _config_number(
+ payload,
+ overrides,
+ "algorithm.selection_negative_quota",
+ int,
+ )
+ )
+
+ if rollout_batch_size <= 0:
+ raise CliError("data.rollout_batch_size must be positive")
+ if n_rollouts <= 1:
+ raise CliError("OraRL requires worker.rollout.n > 1")
+ if not 0.0 < prune_ratio < 1.0:
+ raise CliError("algorithm.selection_prune_ratio must be in (0, 1)")
+
+ keep_rows = max(1, int(n_rollouts * (1.0 - prune_ratio)))
+ if positive_quota < 0 or negative_quota < 0:
+ raise CliError("OraRL selection quotas must be non-negative")
+ if positive_quota + negative_quota + 1 != keep_rows:
+ raise CliError(
+ "OraRL selection quotas must fill the keep budget: "
+ f"positive({positive_quota}) + negative({negative_quota}) + "
+ f"oracle(1) != keep_rows({keep_rows})"
+ )
+
+ world_size = nodes * gpus_per_node
+ selected_batch = rollout_batch_size * keep_rows
+ if selected_batch % world_size:
+ raise CliError(
+ "OraRL selected batch must divide the actor world size: "
+ f"{selected_batch} rows for world_size={world_size}"
+ )
+ preselection_batch = rollout_batch_size * (n_rollouts + 1)
+ if preselection_batch % world_size:
+ raise CliError(
+ "OraRL append-oracle batch must divide the actor world size: "
+ f"{preselection_batch} rows for world_size={world_size}"
+ )
+
+
+def _validated_overrides(values: Sequence[str]) -> list[str]:
+ overrides: list[str] = []
+ for value in values:
+ key, separator, raw_value = value.partition("=")
+ key = key.strip()
+ if not separator or not key or not raw_value.strip():
+ raise CliError(f"--set expects KEY=VALUE, got: {value!r}")
+ if key in _PROTECTED_OVERRIDES:
+ raise CliError(f"use the dedicated option instead of --set for {key}")
+ if key == "algorithm" or key.startswith("algorithm."):
+ raise CliError("algorithm settings must be declared in the public config")
+ if any(character.isspace() for character in key):
+ raise CliError(f"override keys cannot contain whitespace: {key!r}")
+ if _SENSITIVE_OVERRIDE.search(key) or _URI_USERINFO.search(raw_value):
+ raise CliError("credential-bearing overrides are not accepted")
+ overrides.append(f"{key}={raw_value}")
+ return overrides
+
+
+def _override_mapping(values: Sequence[str]) -> dict[str, str]:
+ result: dict[str, str] = {}
+ for value in values:
+ key, _, raw_value = value.partition("=")
+ result[key] = raw_value
+ return result
+
+
+def build_command(namespace: argparse.Namespace) -> tuple[list[str], str]:
+ """Validate ``namespace`` and return the trainer command and method name."""
+
+ config = _config_file(namespace.config)
+ model = _existing_path(namespace.model, "model", kind="directory")
+ train_data = _existing_path(namespace.train_data, "training data", kind="file")
+ val_data = _existing_path(namespace.val_data, "validation data", kind="file")
+ output = _output_path(namespace.output)
+ python = _python_executable(namespace.python)
+ payload = _load_config(config)
+ method = _algorithm_name(payload)
+ validated_overrides = _validated_overrides(namespace.overrides)
+
+ if namespace.nodes <= 0:
+ raise CliError("--nodes must be positive")
+ if namespace.gpus_per_node <= 0:
+ raise CliError("--gpus-per-node must be positive")
+ if method == "orarl":
+ _validate_orarl_batch_layout(
+ payload,
+ _override_mapping(validated_overrides),
+ nodes=namespace.nodes,
+ gpus_per_node=namespace.gpus_per_node,
+ )
+
+ command = [
+ python,
+ "-m",
+ "verl.trainer.main",
+ f"config={config}",
+ *validated_overrides,
+ f"worker.actor.model.model_path={model}",
+ f"worker.actor.model.tokenizer_path={model}",
+ f"data.train_files={train_data}",
+ f"data.val_files={val_data}",
+ f"trainer.save_checkpoint_path={output}",
+ f"trainer.nnodes={namespace.nodes}",
+ f"trainer.n_gpus_per_node={namespace.gpus_per_node}",
+ ]
+ return command, method
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = create_parser()
+ namespace = parser.parse_args(argv)
+ try:
+ command, method = build_command(namespace)
+ except CliError as error:
+ parser.error(str(error))
+
+ print(f"method={method} mode={'dry-run' if namespace.dry_run else 'run'}")
+ print(shlex.join(command))
+ if namespace.dry_run:
+ return 0
+ completed = subprocess.run(command, check=False)
+ return completed.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/code/orarl/data/__init__.py b/code/orarl/data/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b218fda57ece88161985e3c656db4297d0571fd3
--- /dev/null
+++ b/code/orarl/data/__init__.py
@@ -0,0 +1,37 @@
+"""Portable OraRL data preparation utilities."""
+
+from typing import Any
+
+from .identity import media_anchors, media_leakage_tokens, prompt_identity, stable_rank
+from .schema import (
+ CANONICAL_FIELDS,
+ SchemaError,
+ canonicalize_record,
+ media_paths,
+ normalize_text,
+ validate_record,
+ validation_errors,
+)
+
+__all__ = [
+ "CANONICAL_FIELDS",
+ "SchemaError",
+ "build_dataset",
+ "canonicalize_record",
+ "media_anchors",
+ "media_leakage_tokens",
+ "media_paths",
+ "normalize_text",
+ "prompt_identity",
+ "stable_rank",
+ "validate_record",
+ "validation_errors",
+]
+
+
+def build_dataset(*args: Any, **kwargs: Any) -> dict[str, Any]:
+ """Load and call the builder without eagerly importing its CLI module."""
+
+ from .build import build_dataset as _build_dataset
+
+ return _build_dataset(*args, **kwargs)
diff --git a/code/orarl/data/build.py b/code/orarl/data/build.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcd01c2ec5f48559a23f4abd80512106cf5857ab
--- /dev/null
+++ b/code/orarl/data/build.py
@@ -0,0 +1,905 @@
+"""Build a deterministic, leakage-filtered OraRL JSONL mixture.
+
+Run from the OraRL directory with::
+
+ cp configs/data_sources.example.yaml ./data_sources.local.yaml
+ python -m orarl.data.build --config ./data_sources.local.yaml --output ./prepared/train.jsonl
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import sys
+import tempfile
+from collections import Counter, defaultdict
+from collections.abc import Iterable, Mapping, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Optional, Union
+
+from .identity import media_anchors, media_leakage_tokens, prompt_identity, stable_rank
+from .schema import (
+ SchemaError,
+ canonicalize_record,
+ is_remote_reference,
+ read_records,
+)
+
+PathLike = Union[str, os.PathLike[str]]
+
+
+class ConfigError(ValueError):
+ """Raised for an invalid source manifest."""
+
+
+class ShortfallError(RuntimeError):
+ """Raised when a requested train or canary count cannot be reached."""
+
+
+@dataclass(frozen=True)
+class SourceSpec:
+ name: str
+ input_path: Path
+ task: str
+ family: str
+ quota: int
+ media_root: Path
+ preserve_problem_type: bool
+ license: Optional[str]
+ url: Optional[str]
+
+
+@dataclass(frozen=True)
+class Candidate:
+ record: dict[str, Any]
+ spec: SourceSpec
+ source_index: int
+ identity: str
+ anchors: tuple[str, ...]
+ leakage_tokens: frozenset[str]
+ row_digest: str
+
+
+@dataclass(frozen=True)
+class Exclusions:
+ identities: frozenset[str]
+ media_tokens: frozenset[str]
+ rows: int
+ paths: tuple[Path, ...]
+ checksums: Mapping[str, str]
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _json_bytes(value: Any) -> bytes:
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ allow_nan=False,
+ ).encode("utf-8")
+
+
+def _record_digest(record: Mapping[str, Any]) -> str:
+ return hashlib.sha256(_json_bytes(record)).hexdigest()
+
+
+def _atomic_write_jsonl(path: Path, records: Iterable[Mapping[str, Any]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=path.name + ".",
+ suffix=".tmp",
+ dir=str(path.parent),
+ )
+ try:
+ with os.fdopen(descriptor, "wb") as handle:
+ for record in records:
+ handle.write(_json_bytes(record))
+ handle.write(b"\n")
+ os.replace(temporary_name, path)
+ except Exception:
+ try:
+ os.unlink(temporary_name)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+def _atomic_write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=path.name + ".",
+ suffix=".tmp",
+ dir=str(path.parent),
+ )
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ json.dump(
+ value,
+ handle,
+ ensure_ascii=False,
+ sort_keys=True,
+ indent=2,
+ allow_nan=False,
+ )
+ handle.write("\n")
+ os.replace(temporary_name, path)
+ except Exception:
+ try:
+ os.unlink(temporary_name)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+def _load_yaml(path: Path) -> Any:
+ try:
+ import yaml
+ except ImportError as error:
+ raise ConfigError(
+ f"{path}: YAML input requires PyYAML; use a JSON manifest if it is unavailable"
+ ) from error
+ try:
+ with path.open("r", encoding="utf-8") as handle:
+ return yaml.safe_load(handle)
+ except yaml.YAMLError as error:
+ raise ConfigError(f"{path}: invalid YAML: {error}") from error
+
+
+def _load_config(path: Path) -> dict[str, Any]:
+ suffix = path.suffix.casefold()
+ if suffix in {".yaml", ".yml"}:
+ payload = _load_yaml(path)
+ else:
+ try:
+ with path.open("r", encoding="utf-8") as handle:
+ payload = json.load(handle)
+ except json.JSONDecodeError:
+ payload = _load_yaml(path)
+ if not isinstance(payload, dict):
+ raise ConfigError(f"{path}: source manifest must be a mapping")
+ return payload
+
+
+def _local_path(base: Path, value: Any, label: str) -> Path:
+ if not isinstance(value, str) or not value.strip():
+ raise ConfigError(f"{label} must be a nonempty local path")
+ if is_remote_reference(value):
+ raise ConfigError(f"{label} must be local; URL fields are metadata only")
+ path = Path(os.path.expanduser(value.strip()))
+ if not path.is_absolute():
+ path = base / path
+ return Path(os.path.abspath(os.path.normpath(str(path))))
+
+
+def _optional_text(value: Any, label: str) -> Optional[str]:
+ if value is None:
+ return None
+ if not isinstance(value, str) or not value.strip():
+ raise ConfigError(f"{label} must be a nonempty string when supplied")
+ return value.strip()
+
+
+def _integer(value: Any, label: str, minimum: int = 0) -> int:
+ if isinstance(value, bool):
+ raise ConfigError(f"{label} must be an integer")
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError) as error:
+ raise ConfigError(f"{label} must be an integer") from error
+ if parsed != value and not (isinstance(value, str) and str(parsed) == value.strip()):
+ raise ConfigError(f"{label} must be an integer")
+ if parsed < minimum:
+ raise ConfigError(f"{label} must be at least {minimum}")
+ return parsed
+
+
+def _boolean(value: Any, label: str, default: bool) -> bool:
+ if value is None:
+ return default
+ if not isinstance(value, bool):
+ raise ConfigError(f"{label} must be true or false")
+ return value
+
+
+def _source_specs(config_path: Path, config: Mapping[str, Any]) -> list[SourceSpec]:
+ raw_sources = config.get("sources")
+ if not isinstance(raw_sources, list) or not raw_sources:
+ raise ConfigError(f"{config_path}: sources must be a nonempty list")
+
+ specs: list[SourceSpec] = []
+ names: set[str] = set()
+ for index, raw in enumerate(raw_sources):
+ label = f"{config_path}: sources[{index}]"
+ if not isinstance(raw, Mapping):
+ raise ConfigError(f"{label} must be a mapping")
+
+ raw_input = raw.get("input", raw.get("path"))
+ input_path = _local_path(config_path.parent, raw_input, f"{label}.input")
+ raw_name = raw.get("name", raw.get("source"))
+ name = _optional_text(raw_name, f"{label}.name")
+ if name is None:
+ name = input_path.stem
+ if name in names:
+ raise ConfigError(f"{label}.name is duplicated: {name}")
+ names.add(name)
+
+ task = _optional_text(raw.get("task"), f"{label}.task")
+ family = _optional_text(raw.get("family"), f"{label}.family")
+ if task is None or family is None:
+ raise ConfigError(f"{label} must define task and family")
+ quota = _integer(raw.get("quota"), f"{label}.quota")
+ media_root = _local_path(
+ config_path.parent,
+ raw.get("media_root"),
+ f"{label}.media_root",
+ )
+ if not input_path.is_file():
+ raise ConfigError(f"{label}.input does not exist: {input_path}")
+
+ specs.append(
+ SourceSpec(
+ name=name,
+ input_path=input_path,
+ task=task,
+ family=family,
+ quota=quota,
+ media_root=media_root,
+ preserve_problem_type=_boolean(
+ raw.get("preserve_problem_type"),
+ f"{label}.preserve_problem_type",
+ False,
+ ),
+ license=_optional_text(raw.get("license"), f"{label}.license"),
+ url=_optional_text(raw.get("url"), f"{label}.url"),
+ )
+ )
+ return specs
+
+
+def _benchmark_paths(
+ config_path: Path,
+ config: Mapping[str, Any],
+ command_line_paths: Sequence[PathLike],
+) -> list[Path]:
+ raw_paths = config.get(
+ "benchmark_excludes",
+ config.get("benchmark_exclude", []),
+ )
+ if raw_paths is None:
+ raw_paths = []
+ if isinstance(raw_paths, (str, Mapping)):
+ raw_paths = [raw_paths]
+ if not isinstance(raw_paths, list):
+ raise ConfigError(f"{config_path}: benchmark_excludes must be a list")
+
+ paths: list[Path] = []
+ for index, raw in enumerate(raw_paths):
+ value = raw.get("path") if isinstance(raw, Mapping) else raw
+ paths.append(
+ _local_path(
+ config_path.parent,
+ value,
+ f"{config_path}: benchmark_excludes[{index}]",
+ )
+ )
+ for index, raw in enumerate(command_line_paths):
+ paths.append(_local_path(Path.cwd(), str(raw), f"--benchmark-exclude[{index}]"))
+
+ unique: list[Path] = []
+ seen: set[str] = set()
+ for path in paths:
+ key = os.path.normcase(str(path))
+ if key not in seen:
+ seen.add(key)
+ if not path.is_file():
+ raise ConfigError(f"benchmark exclusion file does not exist: {path}")
+ unique.append(path)
+ return unique
+
+
+def _loose_benchmark_record(raw: Mapping[str, Any], root: Path) -> tuple[dict[str, Any], bool]:
+ has_problem = any(
+ isinstance(raw.get(key), str) and bool(str(raw[key]).strip())
+ for key in ("problem", "question", "prompt")
+ )
+ working = dict(raw)
+ if not has_problem:
+ working["problem"] = "media exclusion"
+ working["answer"] = "excluded"
+ if not working.get("problem_type"):
+ working["problem_type"] = "benchmark"
+ if not any(
+ working.get(key)
+ for key in ("images", "videos", "image", "video", "image_path", "video_path")
+ ):
+ generic_media = raw.get("media", raw.get("media_path", raw.get("path")))
+ if generic_media:
+ working["videos"] = generic_media
+ return (
+ canonicalize_record(
+ working,
+ source="benchmark",
+ media_root=root,
+ require_media=False,
+ context="benchmark exclusion",
+ ),
+ has_problem and bool(raw.get("problem_type")),
+ )
+
+
+def _load_exclusions(paths: Sequence[Path]) -> Exclusions:
+ identities: set[str] = set()
+ tokens: set[str] = set()
+ rows = 0
+ checksums: dict[str, str] = {}
+ for path in paths:
+ checksums[str(path)] = _sha256_file(path)
+ for raw in read_records(path):
+ rows += 1
+ explicit_identity = raw.get("prompt_identity", raw.get("identity"))
+ if isinstance(explicit_identity, str) and explicit_identity.strip():
+ identities.add(explicit_identity.strip())
+ try:
+ record, has_full_identity = _loose_benchmark_record(raw, path.parent)
+ except SchemaError:
+ if explicit_identity:
+ continue
+ raise
+ tokens.update(media_leakage_tokens(record))
+ if has_full_identity:
+ identities.add(prompt_identity(record))
+ return Exclusions(
+ identities=frozenset(identities),
+ media_tokens=frozenset(tokens),
+ rows=rows,
+ paths=tuple(paths),
+ checksums=checksums,
+ )
+
+
+def _load_candidates(
+ specs: Sequence[SourceSpec],
+ require_media: bool,
+) -> tuple[list[Candidate], dict[str, int], dict[str, str]]:
+ candidates: list[Candidate] = []
+ input_counts: dict[str, int] = {}
+ checksums: dict[str, str] = {}
+ for spec in specs:
+ raw_records = read_records(spec.input_path)
+ input_counts[spec.name] = len(raw_records)
+ checksums[spec.name] = _sha256_file(spec.input_path)
+ for index, raw in enumerate(raw_records):
+ prepared = dict(raw)
+ if (
+ not spec.preserve_problem_type
+ or not str(prepared.get("problem_type") or "").strip()
+ ):
+ prepared["problem_type"] = spec.task
+ context = f"{spec.input_path}: record {index + 1}"
+ record = canonicalize_record(
+ prepared,
+ source=spec.name,
+ family=spec.family,
+ media_root=spec.media_root,
+ require_media=require_media,
+ context=context,
+ )
+ try:
+ digest = _record_digest(record)
+ except (TypeError, ValueError) as error:
+ raise SchemaError(f"{context}: record is not strict JSON: {error}") from error
+ candidates.append(
+ Candidate(
+ record=record,
+ spec=spec,
+ source_index=index,
+ identity=prompt_identity(record),
+ anchors=media_anchors(record),
+ leakage_tokens=media_leakage_tokens(record),
+ row_digest=digest,
+ )
+ )
+ return candidates, input_counts, checksums
+
+
+def _candidate_order(candidate: Candidate, seed: int, namespace: str) -> tuple[int, str, int]:
+ key = f"{candidate.identity}|{candidate.row_digest}"
+ return (
+ stable_rank(seed, namespace, key),
+ candidate.row_digest,
+ candidate.source_index,
+ )
+
+
+def _select_train(
+ specs: Sequence[SourceSpec],
+ candidates: Sequence[Candidate],
+ seed: int,
+ max_prompts_per_media: int,
+) -> tuple[list[Candidate], dict[str, int], dict[str, int]]:
+ grouped: dict[str, list[Candidate]] = defaultdict(list)
+ for candidate in candidates:
+ grouped[candidate.spec.name].append(candidate)
+
+ unique_capacity = {
+ spec.name: len({candidate.identity for candidate in grouped.get(spec.name, [])})
+ for spec in specs
+ }
+ spec_order = sorted(
+ specs,
+ key=lambda spec: (
+ unique_capacity[spec.name] - spec.quota,
+ spec.name.casefold(),
+ ),
+ )
+
+ selected: list[Candidate] = []
+ selected_identities: set[str] = set()
+ anchor_counts: Counter[str] = Counter()
+ cap_rejections: Counter[str] = Counter()
+ selected_counts: Counter[str] = Counter()
+
+ for spec in spec_order:
+ ordered = sorted(
+ grouped.get(spec.name, []),
+ key=lambda candidate: _candidate_order(
+ candidate,
+ seed,
+ f"train:{spec.name}",
+ ),
+ )
+ for candidate in ordered:
+ if selected_counts[spec.name] >= spec.quota:
+ break
+ if candidate.identity in selected_identities:
+ continue
+ if any(anchor_counts[anchor] >= max_prompts_per_media for anchor in candidate.anchors):
+ cap_rejections[spec.name] += 1
+ continue
+ selected.append(candidate)
+ selected_counts[spec.name] += 1
+ selected_identities.add(candidate.identity)
+ anchor_counts.update(candidate.anchors)
+
+ shortfalls = {
+ spec.name: spec.quota - selected_counts[spec.name]
+ for spec in specs
+ if selected_counts[spec.name] < spec.quota
+ }
+ return selected, shortfalls, dict(cap_rejections)
+
+
+def _select_canary(
+ candidates: Sequence[Candidate],
+ train: Sequence[Candidate],
+ canary_size: int,
+ seed: int,
+ max_prompts_per_media: int,
+) -> tuple[list[Candidate], dict[str, int]]:
+ train_identities = {candidate.identity for candidate in train}
+ train_media_tokens: set[str] = set()
+ for candidate in train:
+ train_media_tokens.update(candidate.leakage_tokens)
+
+ ordered = sorted(
+ candidates,
+ key=lambda candidate: _candidate_order(candidate, seed, "canary"),
+ )
+ canary: list[Candidate] = []
+ canary_identities: set[str] = set()
+ canary_anchor_counts: Counter[str] = Counter()
+ stats: Counter[str] = Counter()
+ for candidate in ordered:
+ if len(canary) >= canary_size:
+ break
+ if candidate.identity in train_identities:
+ stats["train_identity"] += 1
+ continue
+ if candidate.identity in canary_identities:
+ stats["duplicate_identity"] += 1
+ continue
+ if candidate.leakage_tokens & train_media_tokens:
+ stats["train_media"] += 1
+ continue
+ if any(
+ canary_anchor_counts[anchor] >= max_prompts_per_media for anchor in candidate.anchors
+ ):
+ stats["media_cap"] += 1
+ continue
+ canary.append(candidate)
+ canary_identities.add(candidate.identity)
+ canary_anchor_counts.update(candidate.anchors)
+ return canary, dict(stats)
+
+
+def _sorted_output(candidates: Sequence[Candidate], seed: int, namespace: str) -> list[Candidate]:
+ return sorted(
+ candidates,
+ key=lambda candidate: _candidate_order(candidate, seed, namespace),
+ )
+
+
+def _counts(candidates: Sequence[Candidate]) -> dict[str, Any]:
+ by_task = Counter(str(candidate.record["problem_type"]) for candidate in candidates)
+ by_source = Counter(str(candidate.record["source"]) for candidate in candidates)
+ by_family = Counter(str(candidate.record.get("family") or "") for candidate in candidates)
+ return {
+ "rows": len(candidates),
+ "by_task": dict(sorted(by_task.items())),
+ "by_source": dict(sorted(by_source.items())),
+ "by_family": dict(sorted(by_family.items())),
+ }
+
+
+def _default_canary_path(output: Path) -> Path:
+ suffix = output.suffix or ".jsonl"
+ stem = output.name[: -len(output.suffix)] if output.suffix else output.name
+ return output.with_name(stem + ".canary" + suffix)
+
+
+def _default_manifest_path(output: Path) -> Path:
+ stem = output.name[: -len(output.suffix)] if output.suffix else output.name
+ return output.with_name(stem + ".manifest.json")
+
+
+def _output_path(value: PathLike) -> Path:
+ path = Path(os.path.expanduser(str(value)))
+ return Path(os.path.abspath(os.path.normpath(str(path))))
+
+
+def _check_output_paths(
+ paths: Sequence[Path],
+ overwrite: bool,
+ protected_paths: Sequence[Path],
+) -> None:
+ normalized = [os.path.normcase(str(path)) for path in paths]
+ if len(normalized) != len(set(normalized)):
+ raise ConfigError("train, canary, and manifest outputs must be different paths")
+ protected = {os.path.normcase(str(path)) for path in protected_paths}
+ collisions = [
+ str(path)
+ for path, normalized_path in zip(paths, normalized)
+ if normalized_path in protected
+ ]
+ if collisions:
+ raise ConfigError(
+ "output paths cannot replace configs, sources, or exclusions: " + ", ".join(collisions)
+ )
+ if not overwrite:
+ existing = [str(path) for path in paths if path.exists()]
+ if existing:
+ raise FileExistsError(
+ "refusing to overwrite existing output(s): "
+ + ", ".join(existing)
+ + "; pass --overwrite"
+ )
+
+
+def _output_leakage(
+ train: Sequence[Candidate],
+ canary: Sequence[Candidate],
+ exclusions: Exclusions,
+) -> dict[str, int]:
+ train_ids = {candidate.identity for candidate in train}
+ canary_ids = {candidate.identity for candidate in canary}
+ train_tokens: set[str] = set()
+ canary_tokens: set[str] = set()
+ for candidate in train:
+ train_tokens.update(candidate.leakage_tokens)
+ for candidate in canary:
+ canary_tokens.update(candidate.leakage_tokens)
+ return {
+ "benchmark_identity": len((train_ids | canary_ids) & exclusions.identities),
+ "benchmark_media": len((train_tokens | canary_tokens) & exclusions.media_tokens),
+ "train_canary_identity": len(train_ids & canary_ids),
+ "train_canary_media": len(train_tokens & canary_tokens),
+ }
+
+
+def build_dataset(
+ config_path: PathLike,
+ output_path: PathLike,
+ *,
+ canary_output: Optional[PathLike] = None,
+ manifest_output: Optional[PathLike] = None,
+ seed: Optional[int] = None,
+ target: Optional[int] = None,
+ canary_size: Optional[int] = None,
+ max_prompts_per_media: Optional[int] = None,
+ require_media: Optional[bool] = None,
+ benchmark_excludes: Sequence[PathLike] = (),
+ allow_shortfall: Optional[bool] = None,
+ overwrite: bool = False,
+) -> dict[str, Any]:
+ """Build train, canary, and audit manifest files."""
+
+ config_file = _output_path(config_path)
+ if not config_file.is_file():
+ raise ConfigError(f"source manifest does not exist: {config_file}")
+ config = _load_config(config_file)
+ specs = _source_specs(config_file, config)
+
+ effective_seed = _integer(
+ seed if seed is not None else config.get("seed", 42),
+ "seed",
+ )
+ effective_canary_size = _integer(
+ canary_size if canary_size is not None else config.get("canary_size", 0),
+ "canary_size",
+ )
+ effective_cap = _integer(
+ (
+ max_prompts_per_media
+ if max_prompts_per_media is not None
+ else config.get(
+ "max_prompts_per_media",
+ config.get("max_prompts_per_anchor", 1),
+ )
+ ),
+ "max_prompts_per_media",
+ minimum=1,
+ )
+ effective_require_media = (
+ require_media
+ if require_media is not None
+ else _boolean(config.get("require_media"), "require_media", False)
+ )
+ effective_allow_shortfall = (
+ allow_shortfall
+ if allow_shortfall is not None
+ else _boolean(config.get("allow_shortfall"), "allow_shortfall", False)
+ )
+
+ quota_total = sum(spec.quota for spec in specs)
+ effective_target = _integer(
+ target if target is not None else config.get("target", quota_total),
+ "target",
+ )
+ if effective_target != quota_total:
+ raise ConfigError(
+ f"target ({effective_target}) must equal the sum of source quotas ({quota_total})"
+ )
+
+ output = _output_path(output_path)
+ canary_path = (
+ _output_path(canary_output) if canary_output is not None else _default_canary_path(output)
+ )
+ manifest_path = (
+ _output_path(manifest_output)
+ if manifest_output is not None
+ else _default_manifest_path(output)
+ )
+
+ benchmark_paths = _benchmark_paths(config_file, config, benchmark_excludes)
+ protected_paths = [
+ config_file,
+ *(spec.input_path for spec in specs),
+ *benchmark_paths,
+ ]
+ _check_output_paths(
+ (output, canary_path, manifest_path),
+ overwrite,
+ protected_paths,
+ )
+ exclusions = _load_exclusions(benchmark_paths)
+ all_candidates, input_counts, source_checksums = _load_candidates(
+ specs,
+ require_media=effective_require_media,
+ )
+
+ identity_counts = Counter(candidate.identity for candidate in all_candidates)
+ duplicate_rows = sum(count - 1 for count in identity_counts.values())
+ duplicate_groups = sum(count > 1 for count in identity_counts.values())
+
+ eligible: list[Candidate] = []
+ identity_leak_rows = 0
+ media_leak_rows = 0
+ excluded_rows = 0
+ for candidate in all_candidates:
+ identity_hit = candidate.identity in exclusions.identities
+ media_hit = bool(candidate.leakage_tokens & exclusions.media_tokens)
+ identity_leak_rows += int(identity_hit)
+ media_leak_rows += int(media_hit)
+ if identity_hit or media_hit:
+ excluded_rows += 1
+ else:
+ eligible.append(candidate)
+
+ train, source_shortfalls, cap_rejections = _select_train(
+ specs,
+ eligible,
+ effective_seed,
+ effective_cap,
+ )
+ if source_shortfalls and not effective_allow_shortfall:
+ detail = ", ".join(
+ f"{name} short by {count}" for name, count in sorted(source_shortfalls.items())
+ )
+ raise ShortfallError(f"source quota shortfall: {detail}")
+
+ canary, canary_rejections = _select_canary(
+ eligible,
+ train,
+ effective_canary_size,
+ effective_seed,
+ effective_cap,
+ )
+ canary_shortfall = effective_canary_size - len(canary)
+ if canary_shortfall and not effective_allow_shortfall:
+ raise ShortfallError(
+ f"canary shortfall: requested {effective_canary_size}, got {len(canary)}"
+ )
+
+ train = _sorted_output(train, effective_seed, "train-output")
+ canary = _sorted_output(canary, effective_seed, "canary-output")
+ output_leakage = _output_leakage(train, canary, exclusions)
+ if any(output_leakage.values()):
+ raise RuntimeError(f"internal leakage invariant failed: {output_leakage}")
+
+ _atomic_write_jsonl(output, (candidate.record for candidate in train))
+ _atomic_write_jsonl(canary_path, (candidate.record for candidate in canary))
+
+ source_details = []
+ selected_by_source = Counter(candidate.spec.name for candidate in train)
+ for spec in sorted(specs, key=lambda value: value.name.casefold()):
+ source_details.append(
+ {
+ "name": spec.name,
+ "input": str(spec.input_path),
+ "media_root": str(spec.media_root),
+ "task": spec.task,
+ "family": spec.family,
+ "quota": spec.quota,
+ "selected": selected_by_source[spec.name],
+ "shortfall": spec.quota - selected_by_source[spec.name],
+ "preserve_problem_type": spec.preserve_problem_type,
+ "license": spec.license,
+ "url": spec.url,
+ "input_sha256": source_checksums[spec.name],
+ }
+ )
+
+ manifest: dict[str, Any] = {
+ "schema_version": 1,
+ "seed": effective_seed,
+ "target": effective_target,
+ "actual_train_rows": len(train),
+ "canary_target": effective_canary_size,
+ "actual_canary_rows": len(canary),
+ "allow_shortfall": effective_allow_shortfall,
+ "media": {
+ "require_existing": effective_require_media,
+ "max_prompts_per_anchor": effective_cap,
+ },
+ "files": {
+ "train": str(output),
+ "canary": str(canary_path),
+ "manifest": str(manifest_path),
+ },
+ "checksums": {
+ "algorithm": "sha256",
+ "config": _sha256_file(config_file),
+ "train": _sha256_file(output),
+ "canary": _sha256_file(canary_path),
+ "sources": dict(sorted(source_checksums.items())),
+ "benchmark_excludes": dict(sorted(exclusions.checksums.items())),
+ },
+ "counts": {
+ "input_rows": len(all_candidates),
+ "input_by_source": dict(sorted(input_counts.items())),
+ "eligible_rows": len(eligible),
+ "train": _counts(train),
+ "canary": _counts(canary),
+ },
+ "duplicates": {
+ "candidate_prompt_identity_groups": duplicate_groups,
+ "candidate_prompt_rows": duplicate_rows,
+ "train_prompt_rows": len(train) - len({candidate.identity for candidate in train}),
+ "canary_prompt_rows": len(canary) - len({candidate.identity for candidate in canary}),
+ },
+ "leakage": {
+ "benchmark_reference_rows": exclusions.rows,
+ "candidate_identity_rows_excluded": identity_leak_rows,
+ "candidate_media_rows_excluded": media_leak_rows,
+ "candidate_rows_excluded": excluded_rows,
+ "output": output_leakage,
+ },
+ "selection": {
+ "source_shortfalls": dict(sorted(source_shortfalls.items())),
+ "canary_shortfall": canary_shortfall,
+ "media_cap_rejections_by_source": dict(sorted(cap_rejections.items())),
+ "canary_rejections": dict(sorted(canary_rejections.items())),
+ },
+ "sources": source_details,
+ "benchmark_excludes": [str(path) for path in exclusions.paths],
+ }
+ _atomic_write_json(manifest_path, manifest)
+ return manifest
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--config", required=True, help="YAML or JSON source manifest")
+ parser.add_argument("--output", required=True, help="Training JSONL output")
+ parser.add_argument("--canary-output", default=None)
+ parser.add_argument("--manifest-output", default=None)
+ parser.add_argument("--seed", type=int, default=None)
+ parser.add_argument("--target", type=int, default=None)
+ parser.add_argument("--canary-size", type=int, default=None)
+ parser.add_argument(
+ "--max-prompts-per-media",
+ "--max-prompts-per-anchor",
+ dest="max_prompts_per_media",
+ type=int,
+ default=None,
+ )
+ parser.add_argument(
+ "--require-media",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Require every normalized media path to exist",
+ )
+ parser.add_argument(
+ "--require-existing-media",
+ dest="require_media",
+ action="store_true",
+ help=argparse.SUPPRESS,
+ )
+ parser.add_argument(
+ "--benchmark-exclude",
+ action="append",
+ default=[],
+ help="JSON or JSONL benchmark identities/media to exclude; repeatable",
+ )
+ parser.add_argument(
+ "--allow-shortfall",
+ action="store_true",
+ default=None,
+ help="Write available rows instead of failing a source or canary shortfall",
+ )
+ parser.add_argument("--overwrite", action="store_true")
+ return parser
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+ args = build_parser().parse_args(argv)
+ try:
+ manifest = build_dataset(
+ args.config,
+ args.output,
+ canary_output=args.canary_output,
+ manifest_output=args.manifest_output,
+ seed=args.seed,
+ target=args.target,
+ canary_size=args.canary_size,
+ max_prompts_per_media=args.max_prompts_per_media,
+ require_media=args.require_media,
+ benchmark_excludes=args.benchmark_exclude,
+ allow_shortfall=args.allow_shortfall,
+ overwrite=args.overwrite,
+ )
+ except (ConfigError, FileExistsError, OSError, SchemaError, ShortfallError) as error:
+ print(f"[FATAL] {error}", file=sys.stderr)
+ return 2
+ print(
+ "OraRL data prepared: "
+ f"train={manifest['actual_train_rows']} "
+ f"canary={manifest['actual_canary_rows']} "
+ f"manifest={manifest['files']['manifest']}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/code/orarl/data/identity.py b/code/orarl/data/identity.py
new file mode 100644
index 0000000000000000000000000000000000000000..6f5323186e9a61e348ed004c3983f4b459dde73c
--- /dev/null
+++ b/code/orarl/data/identity.py
@@ -0,0 +1,66 @@
+"""Stable prompt and media identities used for sampling and leakage checks."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import unicodedata
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+
+from .schema import media_paths, normalize_text
+
+
+def normalized_media_anchor(path: str) -> str:
+ """Return a case-normalized lexical identity for one local media path."""
+
+ normalized = os.path.normpath(str(path)).replace("\\", "/")
+ return unicodedata.normalize("NFKC", normalized).casefold()
+
+
+def media_anchors(record: Mapping[str, Any]) -> tuple[str, ...]:
+ """Return exact normalized media anchors for cap enforcement."""
+
+ return tuple(sorted({normalized_media_anchor(path) for path in media_paths(record)}))
+
+
+def media_leakage_tokens(record: Mapping[str, Any]) -> frozenset[str]:
+ """Return exact and basename tokens for fail-closed media comparisons."""
+
+ tokens: set[str] = set()
+ for anchor in media_anchors(record):
+ path = Path(anchor)
+ name = normalize_text(path.name)
+ stem = normalize_text(path.stem)
+ tokens.add(f"path:{anchor}")
+ if name:
+ tokens.add(f"name:{name}")
+ if stem:
+ tokens.add(f"stem:{stem}")
+ return frozenset(tokens)
+
+
+def prompt_identity(record: Mapping[str, Any]) -> str:
+ """Hash what is asked and its media, intentionally excluding the answer."""
+
+ payload = {
+ "media": media_anchors(record),
+ "problem": normalize_text(record.get("problem")),
+ "problem_type": normalize_text(record.get("problem_type")),
+ }
+ encoded = json.dumps(
+ payload,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def stable_rank(seed: int, namespace: str, identity: str) -> int:
+ """Create a process-independent deterministic rank."""
+
+ payload = f"{seed}|{namespace}|{identity}".encode("utf-8")
+ return int.from_bytes(hashlib.sha256(payload).digest(), "big")
diff --git a/code/orarl/data/schema.py b/code/orarl/data/schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..15ee1de322566beb0c4f37757927d73bf05b3882
--- /dev/null
+++ b/code/orarl/data/schema.py
@@ -0,0 +1,340 @@
+"""Canonical record handling for portable OraRL data builds."""
+
+from __future__ import annotations
+
+import json
+import math
+import os
+import re
+import unicodedata
+from collections.abc import Iterator, Mapping, Sequence
+from pathlib import Path
+from typing import Any, Optional, Union
+
+CANONICAL_FIELDS = (
+ "problem",
+ "answer",
+ "images",
+ "videos",
+ "problem_type",
+ "source",
+)
+MEDIA_PATH_KEYS = (
+ "path",
+ "video",
+ "image",
+ "video_path",
+ "image_path",
+ "file_name",
+)
+_REMOTE_REFERENCE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
+_MEDIA_MARKER_RE = re.compile(r"<(?:image|video)>", flags=re.IGNORECASE)
+
+
+class SchemaError(ValueError):
+ """Raised when an input row cannot satisfy the canonical schema."""
+
+
+def normalize_text(value: Any) -> str:
+ """Normalize text for identity comparisons, not for model presentation."""
+
+ text = unicodedata.normalize("NFKC", str(value or "")).casefold()
+ text = _MEDIA_MARKER_RE.sub(" ", text)
+ return " ".join(text.split())
+
+
+def is_remote_reference(value: str) -> bool:
+ """Return whether a path asks for a remote resource."""
+
+ return bool(_REMOTE_REFERENCE_RE.match(value.strip()))
+
+
+def normalize_local_path(value: str, root: Optional[Union[str, Path]] = None) -> str:
+ """Normalize a local media path and bind relative paths to ``root``."""
+
+ raw = str(value).strip()
+ if not raw:
+ raise SchemaError("media path must be nonempty")
+ if is_remote_reference(raw):
+ raise SchemaError(f"remote media references are not supported: {raw}")
+
+ path = Path(os.path.expanduser(raw))
+ if not path.is_absolute() and root is not None:
+ path = Path(root) / path
+ return os.path.normpath(str(path))
+
+
+def _message_content(record: Mapping[str, Any], role: str, reverse: bool = False) -> Any:
+ messages = record.get("messages")
+ if not isinstance(messages, list):
+ return None
+ values = reversed(messages) if reverse else messages
+ for message in values:
+ if not isinstance(message, Mapping):
+ continue
+ if str(message.get("role") or "").casefold() == role:
+ content = message.get("content")
+ if content is not None:
+ return content
+ return None
+
+
+def _first_present(record: Mapping[str, Any], keys: Sequence[str]) -> Any:
+ for key in keys:
+ value = record.get(key)
+ if value is not None:
+ return value
+ return None
+
+
+def _as_media_list(value: Any) -> list[Any]:
+ if value is None:
+ return []
+ if isinstance(value, list):
+ return list(value)
+ if isinstance(value, tuple):
+ return list(value)
+ return [value]
+
+
+def _normalize_media_entry(
+ value: Any,
+ media_root: Optional[Union[str, Path]],
+) -> Any:
+ if isinstance(value, str):
+ return normalize_local_path(value, media_root)
+ if not isinstance(value, Mapping):
+ return value
+
+ normalized = dict(value)
+ for key in MEDIA_PATH_KEYS:
+ candidate = normalized.get(key)
+ if isinstance(candidate, str) and candidate.strip():
+ normalized[key] = normalize_local_path(candidate, media_root)
+ return normalized
+
+
+def media_entry_path(value: Any) -> Optional[str]:
+ """Extract the local path represented by one canonical media entry."""
+
+ if isinstance(value, str):
+ return value.strip() or None
+ if isinstance(value, Mapping):
+ for key in MEDIA_PATH_KEYS:
+ candidate = value.get(key)
+ if isinstance(candidate, str) and candidate.strip():
+ return candidate.strip()
+ return None
+
+
+def media_paths(record: Mapping[str, Any]) -> list[str]:
+ """Return de-duplicated image and video paths in stable field order."""
+
+ paths: list[str] = []
+ seen: set[str] = set()
+ for field in ("images", "videos"):
+ values = record.get(field)
+ if not isinstance(values, list):
+ continue
+ for value in values:
+ path = media_entry_path(value)
+ if path is not None and path not in seen:
+ seen.add(path)
+ paths.append(path)
+ return paths
+
+
+def _answer_has_content(value: Any) -> bool:
+ if value is None:
+ return False
+ if isinstance(value, str):
+ return bool(value.strip())
+ if isinstance(value, bool):
+ return True
+ if isinstance(value, int):
+ return True
+ if isinstance(value, float):
+ return math.isfinite(value)
+ if isinstance(value, Mapping):
+ return bool(value) and any(_answer_has_content(item) for item in value.values())
+ if isinstance(value, Sequence):
+ return bool(value) and any(_answer_has_content(item) for item in value)
+ return False
+
+
+def _task_oracle_has_content(record: Mapping[str, Any]) -> bool:
+ if str(record.get("problem_type") or "").strip().casefold() != "segmentation":
+ return False
+ payload = record.get("task_payload")
+ if not isinstance(payload, Mapping):
+ return False
+ output = payload.get("segmentation_output")
+ if not isinstance(output, Mapping):
+ return False
+ if "counts" in output and "size" in output:
+ return _answer_has_content(output["counts"]) and _answer_has_content(output["size"])
+ return any(
+ _answer_has_content(output.get(key))
+ for key in ("segmentation_rle", "rle", "mask", "masks")
+ )
+
+
+def validation_errors(record: Any, require_media: bool = False) -> list[str]:
+ """Collect canonical schema violations for one record."""
+
+ if not isinstance(record, Mapping):
+ return ["record must be a JSON object"]
+
+ errors: list[str] = []
+ for field in ("problem", "problem_type", "source"):
+ value = record.get(field)
+ if not isinstance(value, str) or not value.strip():
+ errors.append(f"{field} must be a nonempty string")
+
+ if (
+ "answer" not in record
+ or (
+ not _answer_has_content(record.get("answer"))
+ and not _task_oracle_has_content(record)
+ )
+ ):
+ errors.append("answer must contain a nonempty oracle label")
+
+ for field in ("images", "videos"):
+ value = record.get(field)
+ if not isinstance(value, list):
+ errors.append(f"{field} must be a list")
+ continue
+ for index, entry in enumerate(value):
+ path = media_entry_path(entry)
+ if path is None:
+ errors.append(f"{field}[{index}] must contain a nonempty local path")
+ continue
+ if is_remote_reference(path):
+ errors.append(f"{field}[{index}] must be a local path")
+ elif require_media and not Path(path).is_file():
+ errors.append(f"{field}[{index}] does not exist: {path}")
+
+ if isinstance(record.get("images"), list) and isinstance(record.get("videos"), list):
+ if not record["images"] and not record["videos"]:
+ errors.append("at least one image or video is required")
+ return errors
+
+
+def validate_record(
+ record: Any,
+ require_media: bool = False,
+ context: str = "record",
+) -> Mapping[str, Any]:
+ """Validate one canonical record and return it unchanged."""
+
+ errors = validation_errors(record, require_media=require_media)
+ if errors:
+ raise SchemaError(f"{context}: " + "; ".join(errors))
+ return record
+
+
+def canonicalize_record(
+ record: Mapping[str, Any],
+ *,
+ problem_type: Optional[str] = None,
+ source: Optional[str] = None,
+ family: Optional[str] = None,
+ media_root: Optional[Union[str, Path]] = None,
+ require_media: bool = False,
+ context: str = "record",
+) -> dict[str, Any]:
+ """Convert common aliases to the canonical OraRL JSONL fields."""
+
+ if not isinstance(record, Mapping):
+ raise SchemaError(f"{context}: record must be a JSON object")
+
+ problem = _first_present(record, ("problem", "question", "prompt"))
+ if problem is None:
+ problem = _message_content(record, "user")
+
+ answer = _first_present(
+ record,
+ ("answer", "ground_truth", "oracle_label", "oracle_labels", "solution"),
+ )
+ if answer is None:
+ answer = _message_content(record, "assistant", reverse=True)
+
+ images = record.get("images")
+ if images is None:
+ images = _first_present(record, ("image", "image_path"))
+ videos = record.get("videos")
+ if videos is None:
+ videos = _first_present(record, ("video", "video_path"))
+
+ canonical = dict(record)
+ canonical.update(
+ {
+ "problem": problem,
+ "answer": answer,
+ "images": [
+ _normalize_media_entry(value, media_root) for value in _as_media_list(images)
+ ],
+ "videos": [
+ _normalize_media_entry(value, media_root) for value in _as_media_list(videos)
+ ],
+ "problem_type": record.get("problem_type") or problem_type,
+ "source": source or record.get("source"),
+ }
+ )
+ if family is not None:
+ canonical["family"] = family
+ for alias in ("image", "video", "image_path", "video_path"):
+ canonical.pop(alias, None)
+
+ validate_record(canonical, require_media=require_media, context=context)
+ return canonical
+
+
+def iter_jsonl(path: Union[str, Path]) -> Iterator[dict[str, Any]]:
+ """Yield JSON objects from a JSONL file with useful line errors."""
+
+ input_path = Path(path)
+ with input_path.open("r", encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, start=1):
+ if not line.strip():
+ continue
+ try:
+ record = json.loads(line)
+ except json.JSONDecodeError as error:
+ raise SchemaError(f"{input_path}:{line_number}: invalid JSON: {error}") from error
+ if not isinstance(record, dict):
+ raise SchemaError(f"{input_path}:{line_number}: expected a JSON object")
+ yield record
+
+
+def read_records(path: Union[str, Path]) -> list[dict[str, Any]]:
+ """Read records from JSONL or a JSON list/object wrapper."""
+
+ input_path = Path(path)
+ if input_path.suffix.casefold() in {".jsonl", ".ndjson"}:
+ return list(iter_jsonl(input_path))
+
+ try:
+ with input_path.open("r", encoding="utf-8") as handle:
+ payload = json.load(handle)
+ except json.JSONDecodeError:
+ return list(iter_jsonl(input_path))
+
+ if isinstance(payload, list):
+ records = payload
+ elif isinstance(payload, dict):
+ records = None
+ for key in ("records", "data", "samples", "results"):
+ if isinstance(payload.get(key), list):
+ records = payload[key]
+ break
+ if records is None:
+ records = [payload]
+ else:
+ raise SchemaError(f"{input_path}: expected a JSON object or list")
+
+ invalid = [index for index, record in enumerate(records) if not isinstance(record, dict)]
+ if invalid:
+ raise SchemaError(f"{input_path}: records at indices {invalid[:5]} are not JSON objects")
+ return list(records)
diff --git a/code/orarl/evaluation/__init__.py b/code/orarl/evaluation/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..314e95ea09751c8c55819438165b53f40e236d74
--- /dev/null
+++ b/code/orarl/evaluation/__init__.py
@@ -0,0 +1,204 @@
+"""Canonical schemas and repository layout for OraRL evaluation data."""
+
+from .card import (
+ DATASET_CARD_FILENAME,
+ DEFAULT_DATASET_REPO_ID,
+ GIT_ATTRIBUTES_FILENAME,
+ DatasetCardError,
+ build_dataset_card_metadata,
+ dataset_card_metadata,
+ dataset_card_subsets,
+ generate_dataset_card,
+ generate_gitattributes,
+ huggingface_dataset_configs,
+ render_dataset_card,
+ render_gitattributes,
+ validate_huggingface_metadata,
+ write_huggingface_metadata,
+)
+from .converters import (
+ ConversionError,
+ ConvertedSource,
+ PlannedAsset,
+ convert_source,
+ convert_sources,
+)
+from .hub import UploadError, upload_evaluation_repository, upload_repository
+from .layout import (
+ ANNOTATIONS_DIRECTORY,
+ ARTIFACTS_DIRECTORY,
+ ASSET_MANIFEST_FILENAME,
+ MANIFEST_FILENAME,
+ MEDIA_DIRECTORY,
+ MEDIA_KINDS,
+ LayoutError,
+ annotation_path,
+ artifact_directory,
+ benchmark_directory,
+ is_dataset_id,
+ media_directory,
+ path_is_within,
+ sha256_file,
+ validate_annotation_path,
+ validate_artifact_path,
+ validate_dataset_id,
+ validate_media_path,
+ validate_repository_assets,
+ validate_repository_path,
+ validate_sha256,
+ validate_unique_paths,
+)
+from .manifest import (
+ DatasetManifestRecord,
+ ManifestError,
+ dataset_manifest_record_errors,
+ load_dataset_manifest,
+ validate_dataset_manifest,
+ validate_dataset_manifest_record,
+ validate_evaluation_repository,
+)
+from .schema import (
+ EVALUATION_CORE_FIELDS,
+ EVALUATION_FIELDS,
+ EVALUATION_SCHEMA_VERSION,
+ EvaluationRow,
+ EvaluationSchemaError,
+ evaluation_asset_paths,
+ evaluation_row_errors,
+ load_evaluation_jsonl,
+ validate_evaluation_row,
+ validate_evaluation_rows,
+)
+from .sources import (
+ EvaluationSource,
+ EvaluationSourceRecord,
+ EvaluationSourceSpec,
+ SourceManifestError,
+ SourceManifestRecord,
+ load_evaluation_source_manifest,
+ load_evaluation_sources,
+ load_source_manifest,
+)
+from .staging import (
+ AssetManifestRecord,
+ StagingError,
+ asset_manifest_record_errors,
+ build_evaluation_data,
+ build_evaluation_repository,
+ build_repository,
+ export_evaluation_index,
+ export_public_evaluation_repository,
+ inventory_and_write_locked_manifest,
+ inventory_evaluation_data,
+ inventory_evaluation_repository,
+ inventory_evaluation_sources,
+ inventory_repository,
+ load_asset_manifest,
+ load_assets_manifest,
+ merge_evaluation_repository,
+ validate_asset_manifest,
+ validate_asset_manifest_record,
+ validate_assets_manifest,
+ validate_evaluation_data_repository,
+ validate_repository,
+ validate_staged_repository,
+ write_locked_source_manifest,
+)
+
+SCHEMA_VERSION = EVALUATION_SCHEMA_VERSION
+
+__all__ = [
+ "ANNOTATIONS_DIRECTORY",
+ "ARTIFACTS_DIRECTORY",
+ "ASSET_MANIFEST_FILENAME",
+ "AssetManifestRecord",
+ "DATASET_CARD_FILENAME",
+ "DEFAULT_DATASET_REPO_ID",
+ "DatasetCardError",
+ "ConversionError",
+ "ConvertedSource",
+ "DatasetManifestRecord",
+ "EVALUATION_CORE_FIELDS",
+ "EVALUATION_FIELDS",
+ "EVALUATION_SCHEMA_VERSION",
+ "EvaluationRow",
+ "EvaluationSchemaError",
+ "EvaluationSource",
+ "EvaluationSourceRecord",
+ "EvaluationSourceSpec",
+ "GIT_ATTRIBUTES_FILENAME",
+ "LayoutError",
+ "MANIFEST_FILENAME",
+ "MEDIA_DIRECTORY",
+ "MEDIA_KINDS",
+ "ManifestError",
+ "PlannedAsset",
+ "SCHEMA_VERSION",
+ "SourceManifestError",
+ "SourceManifestRecord",
+ "StagingError",
+ "UploadError",
+ "annotation_path",
+ "artifact_directory",
+ "benchmark_directory",
+ "asset_manifest_record_errors",
+ "build_evaluation_data",
+ "build_evaluation_repository",
+ "build_dataset_card_metadata",
+ "build_repository",
+ "convert_source",
+ "convert_sources",
+ "dataset_manifest_record_errors",
+ "dataset_card_metadata",
+ "dataset_card_subsets",
+ "evaluation_asset_paths",
+ "evaluation_row_errors",
+ "export_evaluation_index",
+ "export_public_evaluation_repository",
+ "generate_dataset_card",
+ "generate_gitattributes",
+ "huggingface_dataset_configs",
+ "is_dataset_id",
+ "inventory_evaluation_data",
+ "inventory_and_write_locked_manifest",
+ "inventory_evaluation_repository",
+ "inventory_evaluation_sources",
+ "inventory_repository",
+ "load_asset_manifest",
+ "load_assets_manifest",
+ "load_dataset_manifest",
+ "load_evaluation_source_manifest",
+ "load_evaluation_sources",
+ "load_evaluation_jsonl",
+ "load_source_manifest",
+ "media_directory",
+ "merge_evaluation_repository",
+ "path_is_within",
+ "render_dataset_card",
+ "render_gitattributes",
+ "sha256_file",
+ "validate_annotation_path",
+ "validate_artifact_path",
+ "validate_asset_manifest",
+ "validate_asset_manifest_record",
+ "validate_assets_manifest",
+ "validate_dataset_id",
+ "validate_dataset_manifest",
+ "validate_dataset_manifest_record",
+ "validate_evaluation_repository",
+ "validate_evaluation_data_repository",
+ "validate_evaluation_row",
+ "validate_evaluation_rows",
+ "validate_media_path",
+ "validate_repository_assets",
+ "validate_repository_path",
+ "validate_sha256",
+ "validate_repository",
+ "validate_staged_repository",
+ "validate_huggingface_metadata",
+ "validate_unique_paths",
+ "upload_evaluation_repository",
+ "upload_repository",
+ "write_huggingface_metadata",
+ "write_locked_source_manifest",
+]
diff --git a/code/orarl/evaluation/card.py b/code/orarl/evaluation/card.py
new file mode 100644
index 0000000000000000000000000000000000000000..3e9c9ab908b4467ba2f4ff1fe83ff4bc8d964461
--- /dev/null
+++ b/code/orarl/evaluation/card.py
@@ -0,0 +1,954 @@
+"""Deterministic Hugging Face metadata for staged evaluation repositories."""
+
+from __future__ import annotations
+
+import json
+import os
+import tempfile
+from collections.abc import Iterable, Mapping, Sequence
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from .layout import ASSET_MANIFEST_FILENAME
+from .manifest import load_dataset_manifest, validate_dataset_manifest
+from .schema import (
+ EVALUATION_FIELDS,
+ EVALUATION_REQUIRED_FIELDS,
+ EVALUATION_SCHEMA_VERSION,
+)
+
+DATASET_CARD_FILENAME = "README.md"
+GIT_ATTRIBUTES_FILENAME = ".gitattributes"
+DEFAULT_DATASET_REPO_ID = "OraRL/OraRL-Eval"
+DEFAULT_INDEX_REPO_ID = "OraRL/OraRL-Data"
+PUBLIC_EVAL_DATA_DIRECTORY = "OraRL-eval-data"
+PAPER_URL = "https://arxiv.org/abs/2608.20492"
+PROJECT_URL = "https://orarl.github.io/"
+CODE_URL = "https://github.com/HVision-NKU/OraRL"
+
+_PUBLIC_FAMILY_ORDER = (
+ "temporal_grounding",
+ "spatial_grounding",
+ "segmentation",
+ "tracking",
+ "spatial_temporal_grounding",
+ "video_qa",
+ "spatial_intelligence",
+)
+_PUBLIC_FAMILY_LABELS = {
+ "temporal_grounding": "Temporal grounding",
+ "spatial_grounding": "Spatial grounding",
+ "segmentation": "Segmentation",
+ "tracking": "Visual tracking",
+ "spatial_temporal_grounding": "Spatial-temporal grounding",
+ "video_qa": "Video question answering",
+ "spatial_intelligence": "Spatial intelligence",
+}
+_PUBLIC_BENCHMARK_LABELS = {
+ "longvideobench": "LongVideoBench",
+ "mindcube": "MindCube-Tiny",
+ "mlvu": "MLVU",
+ "mmsi": "MMSI-Bench",
+ "mmvu": "MMVU",
+ "mvbench": "MV-Bench",
+ "revsi": "ReVSI",
+ "segmentation": "RefCOCO / + / g, MeViS, ReasonVOS",
+ "spatial_grounding": "RefCOCO / + / g",
+ "stvg": "STVG",
+ "temporal_grounding": "TimeLens: Charades-STA, ActivityNet, QVHighlights",
+ "tracking": "GOT-10k",
+ "videoholmes": "VideoHolmes",
+ "videomme": "VideoMME",
+ "videommev2": "VideoMME-v2",
+ "vsi": "VSI-Bench",
+}
+
+_FIELD_TYPES = {
+ "schema_version": "integer",
+ "eval_task": "string",
+ "sample_id": "string",
+ "benchmark": "string",
+ "split": "string",
+ "problem": "string",
+ "answer": "json",
+ "images": "list[media]",
+ "videos": "list[media]",
+ "problem_type": "string",
+ "source": "string",
+ "family": "string",
+ "choices": "list[string]",
+ "subtitles": "list[media]",
+ "preprocessed": "object",
+ "task_payload": "object",
+ "metadata": "object",
+ "evaluation": "object",
+}
+
+
+class DatasetCardError(ValueError):
+ """Raised when generated Hugging Face metadata is missing or stale."""
+
+
+def _read_jsonl(path: Path) -> list[Mapping[str, Any]]:
+ records: list[Mapping[str, Any]] = []
+ try:
+ with path.open("r", encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, start=1):
+ if not line.strip():
+ continue
+ value = json.loads(line)
+ if not isinstance(value, Mapping):
+ raise DatasetCardError(f"{path}:{line_number}: record must be an object")
+ records.append(value)
+ except json.JSONDecodeError as error:
+ raise DatasetCardError(f"{path}: invalid JSON: {error}") from error
+ except OSError as error:
+ raise DatasetCardError(f"cannot read {path}: {error}") from error
+ return records
+
+
+def dataset_card_subsets(
+ datasets: Iterable[Mapping[str, Any]],
+) -> list[dict[str, Any]]:
+ """Return one deterministic Hugging Face config per benchmark."""
+
+ records = validate_dataset_manifest(datasets)
+ grouped: dict[str, list[Mapping[str, Any]]] = {}
+ for record in records:
+ grouped.setdefault(str(record["benchmark"]), []).append(record)
+
+ configs: list[dict[str, Any]] = []
+ for benchmark in sorted(grouped):
+ data_files = [
+ {
+ "split": str(record["split"]),
+ "path": str(record["annotation_path"]),
+ }
+ for record in sorted(grouped[benchmark], key=lambda item: str(item["split"]))
+ ]
+ configs.append({"config_name": benchmark, "data_files": data_files})
+ return configs
+
+
+def _one_or_many(values: Iterable[str]) -> str | list[str]:
+ ordered = sorted(set(values))
+ return ordered[0] if len(ordered) == 1 else ordered
+
+
+def _size_category(row_count: int) -> str:
+ if row_count < 1_000:
+ return "n<1K"
+ if row_count < 10_000:
+ return "1K1B"
+
+
+def _human_bytes(byte_count: int) -> str:
+ value = float(byte_count)
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
+ if value < 1024.0 or unit == "TiB":
+ return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
+ value /= 1024.0
+ raise AssertionError("unreachable")
+
+
+def _display_value(value: object) -> str:
+ if isinstance(value, list):
+ return ", ".join(str(item) for item in value)
+ return str(value)
+
+
+def _source_links(value: object) -> str:
+ urls = value if isinstance(value, list) else [value]
+ return " ".join(
+ f"[source {index}]({url})" if len(urls) > 1 else f"[source]({url})"
+ for index, url in enumerate(urls, start=1)
+ )
+
+
+def _asset_counts(
+ assets: Sequence[Mapping[str, Any]],
+ *,
+ benchmark: str | None = None,
+ kind: str | None = None,
+) -> tuple[int, int]:
+ selected = [
+ record
+ for record in assets
+ if (benchmark is None or str(record.get("benchmark")) == benchmark)
+ and (kind is None or str(record.get("kind")) == kind)
+ ]
+ return len(selected), sum(int(record.get("bytes", 0)) for record in selected)
+
+
+def dataset_card_metadata(
+ datasets: Iterable[Mapping[str, Any]],
+ assets: Iterable[Mapping[str, Any]],
+) -> dict[str, Any]:
+ """Build deterministic dataset-card YAML metadata and release statistics."""
+
+ records = validate_dataset_manifest(datasets)
+ asset_records = sorted(
+ (dict(record) for record in assets),
+ key=lambda item: str(item.get("path", "")),
+ )
+ grouped: dict[str, list[Mapping[str, Any]]] = {}
+ annotation_bytes = {
+ str(asset.get("path")): int(asset.get("bytes", 0))
+ for asset in asset_records
+ if asset.get("kind") == "annotations"
+ }
+ for record in records:
+ grouped.setdefault(str(record["benchmark"]), []).append(record)
+
+ benchmark_metadata: list[dict[str, Any]] = []
+ dataset_info: list[dict[str, Any]] = []
+ for benchmark in sorted(grouped):
+ benchmark_records = sorted(
+ grouped[benchmark],
+ key=lambda item: str(item["split"]),
+ )
+ asset_count, byte_count = _asset_counts(asset_records, benchmark=benchmark)
+ row_count = sum(int(record["expected_count"]) for record in benchmark_records)
+ split_metadata = [
+ {
+ "name": str(record["split"]),
+ "num_bytes": annotation_bytes.get(str(record["annotation_path"]), 0),
+ "num_examples": int(record["expected_count"]),
+ }
+ for record in benchmark_records
+ ]
+ annotation_byte_count = sum(int(item["num_bytes"]) for item in split_metadata)
+ dataset_info.append(
+ {
+ "config_name": benchmark,
+ "splits": split_metadata,
+ "download_size": byte_count,
+ "dataset_size": annotation_byte_count,
+ }
+ )
+ benchmark_metadata.append(
+ {
+ "config_name": benchmark,
+ "task": _one_or_many(str(record["task"]) for record in benchmark_records),
+ "family": _one_or_many(
+ str(record.get("family", record["task"])) for record in benchmark_records
+ ),
+ "license": _one_or_many(str(record["license"]) for record in benchmark_records),
+ "source_url": _one_or_many(
+ str(record["source_url"]) for record in benchmark_records
+ ),
+ "splits": [str(record["split"]) for record in benchmark_records],
+ "row_count": row_count,
+ "asset_count": asset_count,
+ "byte_count": byte_count,
+ }
+ )
+
+ total_assets, total_bytes = _asset_counts(asset_records)
+ total_rows = sum(int(record["expected_count"]) for record in records)
+ return {
+ "pretty_name": "OraRL Evaluation Data",
+ "license": "other",
+ "tags": [
+ "video",
+ "multimodal",
+ "evaluation",
+ "benchmark",
+ "temporal-grounding",
+ "spatial-grounding",
+ "object-tracking",
+ "video-question-answering",
+ "spatial-reasoning",
+ ],
+ "size_categories": [_size_category(total_rows)],
+ "configs": dataset_card_subsets(records),
+ "dataset_info": dataset_info,
+ "orarl": {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "schema_fields": [
+ {
+ "name": field,
+ "type": _FIELD_TYPES[field],
+ "required": field in EVALUATION_REQUIRED_FIELDS,
+ }
+ for field in EVALUATION_FIELDS
+ ],
+ "totals": {
+ "benchmark_count": len(grouped),
+ "split_count": len(records),
+ "row_count": total_rows,
+ "asset_count": total_assets,
+ "byte_count": total_bytes,
+ },
+ "benchmarks": benchmark_metadata,
+ },
+ }
+
+
+def _public_family_table(metadata: Mapping[str, Any]) -> str:
+ grouped: dict[str, list[Mapping[str, Any]]] = {}
+ for benchmark in metadata["orarl"]["benchmarks"]:
+ family = str(benchmark["family"])
+ grouped.setdefault(family, []).append(benchmark)
+
+ lines = [
+ "| Task family | Benchmarks | Examples |",
+ "| --- | --- | ---: |",
+ ]
+ ordered_families = [
+ *[family for family in _PUBLIC_FAMILY_ORDER if family in grouped],
+ *sorted(set(grouped) - set(_PUBLIC_FAMILY_ORDER)),
+ ]
+ for family in ordered_families:
+ benchmarks = grouped[family]
+ names = [
+ _PUBLIC_BENCHMARK_LABELS.get(
+ str(benchmark["config_name"]),
+ str(benchmark["config_name"]),
+ )
+ for benchmark in benchmarks
+ ]
+ lines.append(
+ f"| {_PUBLIC_FAMILY_LABELS.get(family, family.replace('_', ' ').title())} "
+ f"| {', '.join(names)} "
+ f"| {sum(int(benchmark['row_count']) for benchmark in benchmarks):,} |"
+ )
+ return "\n".join(lines)
+
+
+def _public_provenance_table(metadata: Mapping[str, Any]) -> str:
+ lines = [
+ "| Config | Splits | Rows | Upstream |",
+ "| --- | --- | ---: | --- |",
+ ]
+ for benchmark in metadata["orarl"]["benchmarks"]:
+ splits = ", ".join(f"`{split}`" for split in benchmark["splits"])
+ lines.append(
+ f"| `{benchmark['config_name']}` "
+ f"| {splits} "
+ f"| {int(benchmark['row_count']):,} "
+ f"| {_source_links(benchmark['source_url'])} |"
+ )
+ return "\n".join(lines)
+
+
+def render_public_dataset_card(
+ datasets: Iterable[Mapping[str, Any]],
+ assets: Iterable[Mapping[str, Any]],
+ *,
+ repo_id: str = DEFAULT_INDEX_REPO_ID,
+ data_directory: str = PUBLIC_EVAL_DATA_DIRECTORY,
+) -> str:
+ """Render the concise raw-media card published on Hugging Face."""
+
+ data_directory = data_directory.strip("/")
+ if not data_directory or data_directory in {".", ".."} or ".." in data_directory.split("/"):
+ raise DatasetCardError("public data directory must be a safe relative path")
+ metadata = dataset_card_metadata(datasets, assets)
+ totals = metadata["orarl"]["totals"]
+ metadata["pretty_name"] = "OraRL-Data"
+ for config in metadata["configs"]:
+ for data_file in config["data_files"]:
+ data_file["path"] = f"{data_directory}/{data_file['path']}"
+ public_metadata = {
+ key: value
+ for key, value in metadata.items()
+ if key != "orarl"
+ }
+ yaml_header = yaml.safe_dump(
+ public_metadata,
+ allow_unicode=True,
+ default_flow_style=False,
+ sort_keys=False,
+ width=1000,
+ ).rstrip()
+ family_table = _public_family_table(metadata)
+ provenance_table = _public_provenance_table(metadata)
+
+ body = f"""# OraRL-Data
+
+**The official evaluation data release for Video-ORA and OraRL.**
+
+[Paper]({PAPER_URL}) · [Project page]({PROJECT_URL}) · [Code]({CODE_URL}) ·
+[Video-ORA-9B](https://huggingface.co/OraRL/Video-ORA-9B)
+
+OraRL-Data packages the canonical annotations and referenced raw media used by
+the OraRL evaluation suite: **{totals["row_count"]:,} examples** across
+**{totals["benchmark_count"]} benchmark configs** and
+**{totals["split_count"]} splits**, with
+**{_human_bytes(int(totals["byte_count"]))}** of manifested files.
+The complete evaluation release lives under `{data_directory}/`, leaving room
+for the separate OraRL training release in this repository.
+
+> **Evaluation only.** This release is not training data. Preprocessed tensors
+> and model-specific caches are intentionally excluded.
+
+## Benchmarks
+
+{family_table}
+
+## Download
+
+Download the complete release, including raw media:
+
+```bash
+pip install -U huggingface_hub
+
+hf download {repo_id} \\
+ --repo-type dataset \\
+ --include "{data_directory}/**" \\
+ --local-dir ./OraRL-Data
+```
+
+The Hub download is resumable. `{data_directory}/assets.jsonl` is the
+authoritative file inventory; `{data_directory}/datasets.jsonl` records each
+split's prompt, parser, metric, and preprocessing protocol.
+
+## Load annotations
+
+Each benchmark is exposed as a Hugging Face Datasets config:
+
+```python
+from datasets import load_dataset
+
+dataset = load_dataset("{repo_id}", "videomme", split="test")
+sample = dataset[0]
+print(sample["problem"])
+print(sample["answer"])
+print(sample["videos"])
+```
+
+`images`, `videos`, and `subtitles` contain paths relative to
+`{data_directory}/` in the downloaded snapshot.
+
+## Evaluate Video-ORA
+
+```bash
+orarl-eval \\
+ --dataset ./OraRL-Data/{data_directory} \\
+ --model OraRL/Video-ORA-9B \\
+ --tasks paper \\
+ --summary ./orarl-eval-summary.json \\
+ --run
+```
+
+Exact frame sampling, resolution, prompts, parsers, and metric profiles are
+declared in `{data_directory}/datasets.jsonl`; changing them defines a
+different evaluation setting.
+
+## Repository layout
+
+```text
+OraRL-Data/
+├── README.md
+└── {data_directory}/
+ ├── annotations/ # canonical JSONL evaluation rows
+ ├── media/ # referenced raw images, videos, and subtitles
+ ├── assets.jsonl # released-file inventory
+ └── datasets.jsonl # benchmark and protocol manifest
+```
+
+## License and provenance
+
+OraRL-Data combines multiple upstream benchmarks and therefore uses
+`license: other`. Every benchmark and media item remains subject to its
+original terms; this repository does not replace or broaden those licenses.
+Please cite the relevant upstream datasets in addition to OraRL.
+
+
+Benchmark sources and splits
+
+{provenance_table}
+
+
+
+## Citation
+
+```bibtex
+@article{{li2026orarl,
+ title = {{Annotations as Rollouts: Efficient and Scalable
+ Reinforcement Learning for Video MLLMs}},
+ author = {{Li, Yunheng and Mu, Guohong and Li, Hao and
+ Qian, Shengsheng and Zhang, Dingwen and Hou, Qibin
+ and Cheng, Ming-Ming}},
+ journal = {{arXiv preprint arXiv:2608.20492}},
+ year = {{2026}},
+ url = {{https://arxiv.org/abs/2608.20492}}
+}}
+```
+"""
+ return f"---\n{yaml_header}\n---\n\n{body}"
+
+
+def render_dataset_card(
+ datasets: Iterable[Mapping[str, Any]],
+ assets: Iterable[Mapping[str, Any]],
+ *,
+ repo_id: str = DEFAULT_DATASET_REPO_ID,
+) -> str:
+ """Render the complete deterministic Hugging Face dataset card."""
+
+ asset_records = [dict(record) for record in assets]
+ if repo_id == DEFAULT_INDEX_REPO_ID:
+ return render_public_dataset_card(
+ datasets,
+ asset_records,
+ repo_id=repo_id,
+ )
+ metadata = dataset_card_metadata(datasets, asset_records)
+ yaml_header = yaml.safe_dump(
+ metadata,
+ allow_unicode=True,
+ default_flow_style=False,
+ sort_keys=False,
+ width=1000,
+ ).rstrip()
+ totals = metadata["orarl"]["totals"]
+ benchmark_lines = [
+ "| Config | Family | Splits | Rows | Assets | Size | Terms | Upstream |",
+ "| --- | --- | --- | ---: | ---: | ---: | --- | --- |",
+ ]
+ for benchmark in metadata["orarl"]["benchmarks"]:
+ splits = ", ".join(f"`{split}`" for split in benchmark["splits"])
+ benchmark_lines.append(
+ f"| `{benchmark['config_name']}` "
+ f"| {_display_value(benchmark['family'])} "
+ f"| {splits} "
+ f"| {int(benchmark['row_count']):,} "
+ f"| {int(benchmark['asset_count']):,} "
+ f"| {_human_bytes(int(benchmark['byte_count']))} "
+ f"| {_display_value(benchmark['license'])} "
+ f"| {_source_links(benchmark['source_url'])} |"
+ )
+
+ schema_lines = [
+ "| Field | Type | Required |",
+ "| --- | --- | :---: |",
+ ]
+ for field in metadata["orarl"]["schema_fields"]:
+ schema_lines.append(
+ f"| `{field['name']}` | `{field['type']}` "
+ f"| {'yes' if field['required'] else 'no'} |"
+ )
+ artifact_description = (
+ "- `artifacts/` contains deterministic preprocessed tensors required by selected\n"
+ " paper protocols."
+ if any(record.get("kind") == "artifacts" for record in asset_records)
+ else (
+ "- `artifacts/` is intentionally omitted; evaluators decode the released raw\n"
+ " media using the declared preprocessing profiles."
+ )
+ )
+
+ body = f"""# OraRL-Eval
+
+[Paper]({PAPER_URL}) · [Project page]({PROJECT_URL}) · [Code]({CODE_URL})
+
+This repository contains the canonical, content-addressed evaluation inputs for the
+OraRL paper suite. It has {totals["benchmark_count"]} benchmark configs,
+{totals["split_count"]} splits, {totals["row_count"]:,} rows,
+{totals["asset_count"]:,} manifested assets, and
+{_human_bytes(int(totals["byte_count"]))} of manifested files.
+
+> **Evaluation only.** Do not use these benchmark splits for training,
+> instruction tuning, model selection, or prompt optimization.
+
+## Repository contents
+
+- `datasets.jsonl` declares every benchmark split, preprocessing profile,
+ parser, metric, source, and applicable terms.
+- `annotations/` contains canonical JSONL rows with repository-relative media
+ references.
+- `media/` contains the authorized evaluation images, videos, and subtitles
+ referenced by those rows.
+{artifact_description}
+- `assets.jsonl` records the size and SHA-256 digest of every released file.
+
+## Benchmark configs and provenance
+
+{chr(10).join(benchmark_lines)}
+
+Each config follows its upstream benchmark terms. The repository-level
+`license: other` reflects this mixed-license collection; it does not replace or
+broaden any upstream license.
+
+## Load annotations
+
+Hugging Face Datasets can load one benchmark config at a time:
+
+```python
+from datasets import load_dataset
+
+dataset = load_dataset(
+ "{repo_id}",
+ "temporal_grounding",
+)
+sample = dataset["charades_timelens"][0]
+print(sample["problem"], sample["answer"], sample["videos"])
+```
+
+Media fields are repository-relative paths. For reproducible evaluation,
+download the complete snapshot, including its LFS media and artifacts:
+
+```bash
+hf download {repo_id} \\
+ --repo-type dataset \\
+ --revision main \\
+ --local-dir ./OraRL-Eval
+```
+
+## Canonical row schema
+
+{chr(10).join(schema_lines)}
+
+Task-specific targets remain structured in `answer`, `task_payload`, and
+`metadata`. Evaluation behavior is fixed by the per-row `evaluation` profile
+and the corresponding declaration in `datasets.jsonl`.
+
+## Run the paper evaluation
+
+```bash
+orarl-eval \\
+ --dataset {repo_id} \\
+ --model /path/to/exported-model \\
+ --tasks paper \\
+ --summary ./orarl-eval-summary.json \\
+ --run
+```
+
+Use the same command with `--dataset /path/to/OraRL-Eval` for a validated local
+snapshot. The evaluator selects configs by their `task` metadata and resolves all
+annotation, media, subtitle, and preprocessing paths relative to the snapshot.
+
+## Integrity and reproducibility
+
+`datasets.jsonl` declares each benchmark split and evaluation protocol.
+`assets.jsonl` records every staged annotation and asset with its byte size and
+SHA-256 digest. Validate a clean snapshot before evaluation:
+
+```bash
+orarl-eval-data validate --root /path/to/OraRL-Eval
+```
+
+The released preprocessing profiles are protocol-specific. Changing frame
+sampling, resolution, prompt templates, answer parsing, or aggregation can
+change benchmark scores and should be reported as a different setting.
+
+## Intended use and limitations
+
+OraRL-Eval is intended to reproduce and extend research evaluation across
+temporal grounding, tracking, segmentation, spatial grounding,
+spatial-temporal grounding, video question answering, and spatial intelligence.
+It is not a training corpus.
+
+The collection inherits coverage gaps, annotation errors, representational
+biases, and potentially sensitive visual content from its upstream benchmarks.
+Users are responsible for complying with upstream access conditions, privacy
+requirements, and media licenses. Scores are not evidence of safety,
+reliability, or suitability for deployment.
+
+## Citation
+
+```bibtex
+@article{{li2026orarl,
+ title = {{Annotations as Rollouts: Efficient and Scalable
+ Reinforcement Learning for Video MLLMs}},
+ author = {{Li, Yunheng and Mu, Guohong and Li, Hao and
+ Qian, Shengsheng and Zhang, Dingwen and Hou, Qibin
+ and Cheng, Ming-Ming}},
+ journal = {{arXiv preprint arXiv:2608.20492}},
+ year = {{2026}},
+ url = {{https://arxiv.org/abs/2608.20492}}
+}}
+```
+
+Please also cite the original benchmark sources listed above when using their
+data or reporting results.
+"""
+ return f"---\n{yaml_header}\n---\n\n{body}"
+
+
+def render_index_card(
+ datasets: Iterable[Mapping[str, Any]],
+ annotation_assets: Iterable[Mapping[str, Any]],
+ *,
+ repo_id: str = DEFAULT_INDEX_REPO_ID,
+) -> str:
+ """Render the annotation-only Hugging Face dataset card."""
+
+ metadata = dataset_card_metadata(datasets, annotation_assets)
+ metadata["pretty_name"] = "OraRL-Data"
+ yaml_header = yaml.safe_dump(
+ metadata,
+ allow_unicode=True,
+ default_flow_style=False,
+ sort_keys=False,
+ width=1000,
+ ).rstrip()
+ totals = metadata["orarl"]["totals"]
+ benchmark_lines = [
+ "| Config | Family | Splits | Rows | Terms | Upstream |",
+ "| --- | --- | --- | ---: | --- | --- |",
+ ]
+ for benchmark in metadata["orarl"]["benchmarks"]:
+ splits = ", ".join(f"`{split}`" for split in benchmark["splits"])
+ benchmark_lines.append(
+ f"| `{benchmark['config_name']}` "
+ f"| {_display_value(benchmark['family'])} "
+ f"| {splits} "
+ f"| {int(benchmark['row_count']):,} "
+ f"| {_display_value(benchmark['license'])} "
+ f"| {_source_links(benchmark['source_url'])} |"
+ )
+
+ body = f"""# OraRL-Data
+
+[Paper]({PAPER_URL}) · [Project page]({PROJECT_URL}) · [Code]({CODE_URL})
+
+This is the portable, annotation-only evaluation index for OraRL. It contains
+{totals["benchmark_count"]} benchmark configs, {totals["split_count"]} splits,
+and {totals["row_count"]:,} canonical evaluation rows.
+
+> **No media is redistributed.** Raw images, raw videos, subtitles, and derived
+> preprocessing artifacts are intentionally excluded. Users must obtain them
+> from the upstream benchmark sources under their respective terms.
+
+> **Evaluation only.** Do not use these benchmark splits for training,
+> instruction tuning, model selection, or prompt optimization.
+
+## Repository contents
+
+- `datasets.jsonl` declares each split, preprocessing profile, parser, metric,
+ source, and applicable terms.
+- `annotations/` contains canonical JSONL rows with portable repository-relative
+ media and artifact references.
+- `media/`, `artifacts/`, and `assets.jsonl` are intentionally absent.
+
+## Benchmark configs and provenance
+
+{chr(10).join(benchmark_lines)}
+
+The repository-level `license: other` reflects the mixed upstream terms. It does
+not replace or broaden any benchmark license.
+
+## Load annotations
+
+Hugging Face Datasets can load one benchmark config at a time without downloading
+media:
+
+```python
+from datasets import load_dataset
+
+dataset = load_dataset("{repo_id}", "segmentation")
+sample = dataset["mevis"][0]
+print(sample["problem"], sample["videos"])
+```
+
+## Prepare assets and evaluate
+
+Download this index, obtain the corresponding benchmark media from the upstream
+links above, and arrange it according to the repository-relative paths declared
+in the annotations. The OraRL evaluator keeps metadata and licensed assets
+separate:
+
+```bash
+hf download {repo_id} \\
+ --repo-type dataset \\
+ --revision main \\
+ --local-dir ./OraRL-Data
+
+orarl-eval \\
+ --dataset ./OraRL-Data \\
+ --asset-root /path/to/local/OraRL-Eval-assets \\
+ --model /path/to/Video-ORA-9B \\
+ --tasks paper \\
+ --summary ./orarl-eval-summary.json \\
+ --run
+```
+
+The released preprocessing profiles are protocol-specific. Changing frame
+sampling, video reader, resolution, prompt templates, answer parsing, or
+aggregation can change benchmark scores and should be reported as a different
+setting. Video segmentation uses Decord, matching the archived paper evaluation.
+
+## Canonical row schema
+
+Rows use the fields documented by the OraRL evaluation schema, including
+`problem`, `answer`, `images`, `videos`, `problem_type`, `source`, `family`, and
+task-specific `task_payload`, `metadata`, and `evaluation` objects. Media values
+are logical paths resolved against a separately prepared `--asset-root`.
+
+## Intended use and limitations
+
+OraRL-Data supports reproducible evaluation across temporal grounding, tracking,
+segmentation, spatial grounding, spatial-temporal grounding, video question
+answering, and spatial intelligence. It inherits annotation errors, coverage
+gaps, biases, and potentially sensitive content from the upstream benchmarks.
+Users are responsible for all access conditions, privacy requirements, and
+licenses.
+
+## Citation
+
+```bibtex
+@article{{li2026orarl,
+ title = {{Annotations as Rollouts: Efficient and Scalable
+ Reinforcement Learning for Video MLLMs}},
+ author = {{Li, Yunheng and Mu, Guohong and Li, Hao and
+ Qian, Shengsheng and Zhang, Dingwen and Hou, Qibin
+ and Cheng, Ming-Ming}},
+ journal = {{arXiv preprint arXiv:2608.20492}},
+ year = {{2026}},
+ url = {{https://arxiv.org/abs/2608.20492}}
+}}
+```
+
+Please also cite the original benchmark sources when using their annotations or
+reporting results.
+"""
+ return f"---\n{yaml_header}\n---\n\n{body}"
+
+
+def render_gitattributes(
+ datasets: Iterable[Mapping[str, Any]],
+ *,
+ path_prefix: str | None = None,
+) -> str:
+ """Render deterministic LFS rules for all declared data paths."""
+
+ records = validate_dataset_manifest(datasets)
+ prefix = path_prefix.strip("/") if path_prefix else ""
+ patterns: set[str] = set()
+ for record in records:
+ for path in (*record["media_paths"], *record["artifact_paths"]):
+ pattern = f"{path}/**"
+ patterns.add(f"{prefix}/{pattern}" if prefix else pattern)
+ lines = ["# Generated by orarl-eval-data; do not edit."]
+ lines.extend(f"{pattern} filter=lfs diff=lfs merge=lfs -text" for pattern in sorted(patterns))
+ return "\n".join(lines) + "\n"
+
+
+def _atomic_write_text(path: Path, content: str) -> None:
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{path.name}.",
+ suffix=".tmp",
+ dir=str(path.parent),
+ )
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
+ handle.write(content)
+ os.replace(temporary_name, path)
+ except Exception:
+ try:
+ os.unlink(temporary_name)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+def _repository_records(
+ repository_root: str | os.PathLike[str],
+) -> tuple[Path, list[Mapping[str, Any]], list[Mapping[str, Any]]]:
+ root = Path(repository_root).expanduser().resolve()
+ datasets = load_dataset_manifest(root)
+ assets = _read_jsonl(root / ASSET_MANIFEST_FILENAME)
+ return root, datasets, assets
+
+
+def write_huggingface_metadata(
+ repository_root: str | os.PathLike[str],
+ *,
+ repo_id: str = DEFAULT_DATASET_REPO_ID,
+) -> dict[str, Any]:
+ """Generate ``README.md`` and ``.gitattributes`` from staged manifests."""
+
+ root, datasets, assets = _repository_records(repository_root)
+ card = render_dataset_card(datasets, assets, repo_id=repo_id)
+ attributes = render_gitattributes(
+ datasets,
+ path_prefix=(
+ PUBLIC_EVAL_DATA_DIRECTORY
+ if repo_id == DEFAULT_INDEX_REPO_ID
+ else None
+ ),
+ )
+ _atomic_write_text(root / DATASET_CARD_FILENAME, card)
+ _atomic_write_text(root / GIT_ATTRIBUTES_FILENAME, attributes)
+ return {
+ "readme": DATASET_CARD_FILENAME,
+ "gitattributes": GIT_ATTRIBUTES_FILENAME,
+ "configs": len(dataset_card_subsets(datasets)),
+ }
+
+
+def validate_huggingface_metadata(
+ repository_root: str | os.PathLike[str],
+ *,
+ repo_id: str = DEFAULT_DATASET_REPO_ID,
+) -> dict[str, Any]:
+ """Require generated metadata to match the staged manifests byte-for-byte."""
+
+ root, datasets, assets = _repository_records(repository_root)
+ path_prefix = (
+ PUBLIC_EVAL_DATA_DIRECTORY
+ if repo_id == DEFAULT_INDEX_REPO_ID
+ else None
+ )
+ expected = {
+ DATASET_CARD_FILENAME: render_dataset_card(datasets, assets, repo_id=repo_id),
+ GIT_ATTRIBUTES_FILENAME: render_gitattributes(
+ datasets,
+ path_prefix=path_prefix,
+ ),
+ }
+ for name, content in expected.items():
+ path = root / name
+ if path.is_symlink() or not path.is_file():
+ raise DatasetCardError(f"generated Hugging Face metadata is missing: {name}")
+ if path.read_bytes() != content.encode("utf-8"):
+ raise DatasetCardError(f"generated Hugging Face metadata is stale: {name}")
+ return {
+ "readme": DATASET_CARD_FILENAME,
+ "gitattributes": GIT_ATTRIBUTES_FILENAME,
+ "configs": len(dataset_card_subsets(datasets)),
+ }
+
+
+huggingface_dataset_configs = dataset_card_subsets
+build_dataset_card_metadata = dataset_card_metadata
+generate_dataset_card = render_dataset_card
+generate_gitattributes = render_gitattributes
+
+
+__all__ = [
+ "DATASET_CARD_FILENAME",
+ "DEFAULT_DATASET_REPO_ID",
+ "DEFAULT_INDEX_REPO_ID",
+ "DatasetCardError",
+ "GIT_ATTRIBUTES_FILENAME",
+ "PUBLIC_EVAL_DATA_DIRECTORY",
+ "build_dataset_card_metadata",
+ "dataset_card_metadata",
+ "dataset_card_subsets",
+ "generate_dataset_card",
+ "generate_gitattributes",
+ "huggingface_dataset_configs",
+ "render_dataset_card",
+ "render_index_card",
+ "render_public_dataset_card",
+ "render_gitattributes",
+ "validate_huggingface_metadata",
+ "write_huggingface_metadata",
+]
diff --git a/code/orarl/evaluation/converters.py b/code/orarl/evaluation/converters.py
new file mode 100644
index 0000000000000000000000000000000000000000..7dda8a50dce65ea7af3fe256cc480842f855f856
--- /dev/null
+++ b/code/orarl/evaluation/converters.py
@@ -0,0 +1,1819 @@
+"""Benchmark adapters that produce portable canonical evaluation rows."""
+
+from __future__ import annotations
+
+import ast
+import base64
+import binascii
+import csv
+import hashlib
+import io
+import json
+import math
+import os
+import re
+import sys
+from collections.abc import Mapping, Sequence
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from orarl.data.schema import MEDIA_PATH_KEYS, read_records
+
+from .layout import annotation_path, artifact_directory, media_directory, sha256_file
+from .schema import EVALUATION_SCHEMA_VERSION, EvaluationSchemaError, validate_evaluation_rows
+from .sources import GENERIC_ADAPTERS, EvaluationSource
+
+_PATH_KEY_RE = re.compile(
+ r"(?:^|_)(?:artifact|artifacts|dir|directory|file|files|image|images|media|"
+ r"path|paths|root|subtitle|subtitles|tensor|tensors|video|videos)$"
+)
+_WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]")
+_IMAGE_SUFFIXES = frozenset({".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tif", ".webp"})
+_VIDEO_SUFFIXES = frozenset({".avi", ".mkv", ".mov", ".mp4", ".mpeg", ".webm"})
+_SUBTITLE_SUFFIXES = (".srt", ".vtt", ".json", ".txt")
+_ARTIFACT_SUFFIXES = (".npz", ".npy", ".safetensors", ".pt", ".pth", ".pkl")
+_GROUND_TRUTH_ANSWER_ALIAS = "_".join(("gt", "answer"))
+_GROUND_TRUTH_BBOX_ALIAS = "_".join(("gt", "bbox"))
+_MINDCUBE_EXPECTED_GROUP_COUNTS = {
+ "rotation": 200,
+ "among": 600,
+ "around": 250,
+}
+_REVSI_EXPECTED_GROUP_COUNTS = {
+ "object_abs_distance": 1232,
+ "object_counting": 1091,
+ "object_rel_direction": 1387,
+ "object_rel_distance": 1351,
+ "object_size_estimation": 1134,
+ "room_size_estimation": 302,
+ "route_planning": 311,
+}
+_PAYLOAD_KEYS = frozenset(
+ {
+ "bbox",
+ "bboxes",
+ "boxes",
+ "coordinates",
+ "duration",
+ "end",
+ "frame",
+ "frame_id",
+ "frame_ids",
+ _GROUND_TRUTH_BBOX_ALIAS,
+ "mask",
+ "masks",
+ "normalized_solution",
+ "object",
+ "segment",
+ "segmentation",
+ "segmentation_output",
+ "span",
+ "spans",
+ "start",
+ "target",
+ "timestamp",
+ "timestamps",
+ "trajectory",
+ }
+)
+_BASE_CONSUMED_FIELDS = frozenset(
+ {
+ "answer",
+ "answers",
+ "bench",
+ "benchmark",
+ "caption",
+ "choices",
+ "data_source",
+ "data_type",
+ "dataset",
+ "eval_task",
+ "evaluation",
+ "family",
+ "file_name",
+ "ground_truth",
+ _GROUND_TRUTH_ANSWER_ALIAS,
+ _GROUND_TRUTH_BBOX_ALIAS,
+ "id",
+ "image",
+ "image_path",
+ "images",
+ "index",
+ "input_prompt",
+ "media",
+ "media_path",
+ "messages",
+ "metadata",
+ "normal_caption",
+ "options",
+ "oracle_label",
+ "oracle_labels",
+ "path",
+ "preprocessed",
+ "preprocessed_video",
+ "problem",
+ "problem_id",
+ "problem_type",
+ "prompt",
+ "question",
+ "question_id",
+ "question_type",
+ "sample_id",
+ "schema_version",
+ "solution",
+ "source",
+ "split",
+ "subtitle_file",
+ "subtitle_path",
+ "subtitles",
+ "task_payload",
+ "task_type",
+ "video",
+ "video_path",
+ "videos",
+ }
+)
+_DROP = object()
+
+
+class ConversionError(ValueError):
+ """Raised when a benchmark source cannot become canonical rows."""
+
+
+@dataclass(frozen=True)
+class PlannedAsset:
+ """One content-addressed output asset and its private local source."""
+
+ repository_path: str
+ sha256: str | None
+ bytes: int
+ kind: str
+ benchmark: str
+ license: str
+ source_path: Path | None = None
+ content: bytes | None = None
+ missing: bool = False
+
+
+@dataclass(frozen=True)
+class ConvertedSource:
+ """Canonical rows and assets produced for one source record."""
+
+ source: EvaluationSource
+ rows: tuple[dict[str, Any], ...]
+ assets: tuple[PlannedAsset, ...]
+ source_rows: int
+
+ @property
+ def missing_assets(self) -> tuple[PlannedAsset, ...]:
+ return tuple(asset for asset in self.assets if asset.missing)
+
+
+def _json_bytes(value: Any) -> bytes:
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ allow_nan=False,
+ ).encode("utf-8")
+
+
+def _canonical_suffix(path: Path) -> str:
+ suffix = path.suffix.casefold()
+ aliases = {".jpeg": ".jpg", ".tiff": ".tif"}
+ suffix = aliases.get(suffix, suffix)
+ if not re.fullmatch(r"\.[a-z0-9]{1,12}", suffix):
+ return ""
+ return suffix
+
+
+def _image_suffix(content: bytes) -> str:
+ if content.startswith(b"\xff\xd8\xff"):
+ return ".jpg"
+ if content.startswith(b"\x89PNG\r\n\x1a\n"):
+ return ".png"
+ if content.startswith((b"GIF87a", b"GIF89a")):
+ return ".gif"
+ if content.startswith(b"BM"):
+ return ".bmp"
+ if content[:4] in {b"II*\x00", b"MM\x00*"}:
+ return ".tif"
+ if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
+ return ".webp"
+ return ".bin"
+
+
+def _is_path_key(key: object) -> bool:
+ return isinstance(key, str) and bool(_PATH_KEY_RE.search(key.casefold()))
+
+
+def _is_private_path(value: str, source: EvaluationSource) -> bool:
+ stripped = value.strip()
+ if (
+ stripped.startswith(("/", "~/", "file://"))
+ or _WINDOWS_ABSOLUTE_RE.match(stripped)
+ ):
+ return True
+ private_roots = {
+ str(source.annotation_input),
+ str(source.annotation_input.parent),
+ *(str(path) for path in source.media_roots.values()),
+ *(str(path) for path in source.preprocessed_roots.values()),
+ }
+ return any(root and root in stripped for root in private_roots)
+
+
+def _json_safe(value: Any, source: EvaluationSource) -> Any:
+ if value is None or isinstance(value, (bool, int)):
+ return value
+ if isinstance(value, float):
+ return value if math.isfinite(value) else _DROP
+ if isinstance(value, str):
+ return _DROP if _is_private_path(value, source) else value
+ if isinstance(value, (bytes, bytearray, memoryview, Path)):
+ return _DROP
+ if isinstance(value, Mapping):
+ converted: dict[str, Any] = {}
+ for raw_key, item in value.items():
+ if not isinstance(raw_key, str) or _is_private_path(raw_key, source):
+ continue
+ safe = _json_safe(item, source)
+ if safe is not _DROP:
+ converted[raw_key] = safe
+ return converted
+ if isinstance(value, Sequence):
+ converted_items = []
+ for item in value:
+ safe = _json_safe(item, source)
+ if safe is not _DROP:
+ converted_items.append(safe)
+ return converted_items
+
+ item_method = getattr(value, "item", None)
+ if callable(item_method):
+ try:
+ scalar = item_method()
+ except (TypeError, ValueError):
+ scalar = value
+ if scalar is not value:
+ return _json_safe(scalar, source)
+ list_method = getattr(value, "tolist", None)
+ if callable(list_method):
+ try:
+ listed = list_method()
+ except (TypeError, ValueError):
+ listed = value
+ if listed is not value:
+ return _json_safe(listed, source)
+ isoformat = getattr(value, "isoformat", None)
+ if callable(isoformat):
+ try:
+ return str(isoformat())
+ except (TypeError, ValueError):
+ pass
+ return _DROP
+
+
+def _as_list(value: Any) -> list[Any]:
+ if value is None:
+ return []
+ list_method = getattr(value, "tolist", None)
+ if callable(list_method) and not isinstance(value, (str, bytes, bytearray)):
+ try:
+ value = list_method()
+ except (TypeError, ValueError):
+ pass
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ return list(value)
+ return [value]
+
+
+def _is_blank_path_reference(value: Any) -> bool:
+ if not isinstance(value, (str, os.PathLike)):
+ return False
+ raw = os.fspath(value)
+ return not raw.strip()
+
+
+def _first(record: Mapping[str, Any], fields: Sequence[str]) -> Any:
+ for field in fields:
+ value = record.get(field)
+ if value is not None and not _is_blank_path_reference(value):
+ return value
+ return None
+
+
+def _message_content(
+ record: Mapping[str, Any],
+ role: str,
+ *,
+ reverse: bool = False,
+) -> Any:
+ messages = record.get("messages")
+ if not isinstance(messages, list):
+ return None
+ candidates = reversed(messages) if reverse else messages
+ for message in candidates:
+ if not isinstance(message, Mapping):
+ continue
+ if str(message.get("role") or "").casefold() != role:
+ continue
+ content = message.get("content")
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ text = "\n".join(
+ str(item.get("text"))
+ for item in content
+ if isinstance(item, Mapping) and item.get("text") is not None
+ ).strip()
+ if text:
+ return text
+ return None
+
+
+class _AssetPlanner:
+ def __init__(
+ self,
+ source: EvaluationSource,
+ *,
+ hash_assets: bool = False,
+ ):
+ self.source = source
+ self.hash_assets = hash_assets
+ self._assets: dict[str, PlannedAsset] = {}
+ self._source_paths: dict[tuple[str, str], str] = {}
+ self._missing_paths: dict[tuple[str, str], str] = {}
+ self._references: dict[tuple[str, str, str], tuple[str, Path]] = {}
+ self._resolved_paths: dict[tuple[str, str], Path] = {}
+ self._embedded_counts: dict[str, int] = {}
+
+ @property
+ def assets(self) -> tuple[PlannedAsset, ...]:
+ return tuple(
+ sorted(
+ self._assets.values(),
+ key=lambda asset: (asset.repository_path, asset.missing),
+ )
+ )
+
+ def _destination(self, kind: str, digest: str, suffix: str) -> str:
+ filename = digest + suffix
+ if kind == "artifacts":
+ return f"{artifact_directory(self.source.benchmark)}/{filename}"
+ return f"{media_directory(self.source.benchmark, kind)}/{filename}"
+
+ def _source_destination(self, kind: str, path: Path, root: Path) -> str:
+ try:
+ relative = path.relative_to(root.resolve())
+ except ValueError:
+ relative = Path(path.name)
+ raw_name = "__".join(relative.parts)
+ filename = re.sub(r"[^A-Za-z0-9._+-]+", "_", raw_name).strip("_")
+ if not filename or filename in {".", ".."}:
+ raise ConversionError(f"asset path has no portable filename: {path}")
+ if len(filename.encode("utf-8")) > 220:
+ suffix = _canonical_suffix(path)
+ stem = filename[: -len(suffix)] if suffix and filename.endswith(suffix) else filename
+ path_token = hashlib.blake2b(
+ relative.as_posix().encode("utf-8"),
+ digest_size=10,
+ ).hexdigest()
+ stem_limit = 220 - len(suffix.encode("utf-8")) - len(path_token) - 2
+ filename = f"{stem[:stem_limit]}--{path_token}{suffix}"
+ if kind == "artifacts":
+ directory = artifact_directory(self.source.benchmark)
+ else:
+ directory = media_directory(self.source.benchmark, kind)
+ return f"{directory}/{self.source.split}/{filename}"
+
+ @staticmethod
+ def _reference_key(
+ reference: object,
+ root: Path,
+ kind: str,
+ ) -> tuple[str, str, str]:
+ if not isinstance(reference, (str, os.PathLike)):
+ raise ConversionError("asset reference must be a local path string")
+ raw_reference = os.fspath(reference).strip()
+ return (
+ kind,
+ os.path.normcase(os.path.abspath(os.path.normpath(str(root)))),
+ os.path.normcase(os.path.normpath(raw_reference)),
+ )
+
+ def resolve(self, reference: object, root: Path) -> Path:
+ if not isinstance(reference, (str, os.PathLike)):
+ raise ConversionError("asset reference must be a local path string")
+ raw = os.fspath(reference).strip()
+ if not raw:
+ raise ConversionError("asset reference must be nonempty")
+ if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", raw):
+ raise ConversionError(f"remote asset references are unsupported: {raw}")
+ resolved_key = (
+ os.path.normcase(os.path.abspath(os.path.normpath(str(root)))),
+ os.path.normcase(os.path.normpath(raw)),
+ )
+ cached = self._resolved_paths.get(resolved_key)
+ if cached is not None:
+ return cached
+
+ candidate = Path(os.path.expanduser(raw))
+ if candidate.is_absolute():
+ candidates = [candidate]
+ else:
+ normalized = Path(os.path.normpath(raw))
+ candidates = [root / normalized]
+ if normalized.parts and normalized.parts[0].casefold() == root.name.casefold():
+ candidates.append(root.parent / normalized)
+ if normalized.parts and normalized.parts[0].casefold() == "evaluation":
+ candidates.append(root.joinpath(*normalized.parts[1:]))
+ candidates.extend(
+ (
+ self.source.annotation.parent / normalized,
+ root / normalized.name,
+ )
+ )
+ unique: list[Path] = []
+ seen: set[str] = set()
+ for path in candidates:
+ normalized_path = Path(os.path.abspath(os.path.normpath(str(path))))
+ key = os.path.normcase(str(normalized_path))
+ if key not in seen:
+ seen.add(key)
+ unique.append(normalized_path)
+ for path in unique:
+ if path.is_file():
+ self._resolved_paths[resolved_key] = path
+ return path
+ self._resolved_paths[resolved_key] = unique[0]
+ return unique[0]
+
+ def mapped_path(
+ self,
+ reference: object,
+ root: Path,
+ *,
+ kind: str,
+ ) -> str | None:
+ reference_key = self._reference_key(reference, root, kind)
+ cached = self._references.get(reference_key)
+ if cached is not None:
+ return cached[0]
+ path = self.resolve(reference, root)
+ mapped = self._source_paths.get((kind, os.path.normcase(str(path))))
+ if mapped is not None:
+ self._references[reference_key] = (mapped, path)
+ return mapped
+
+ def add_file(self, reference: object, *, kind: str, root: Path) -> tuple[str, Path]:
+ reference_key = self._reference_key(reference, root, kind)
+ cached = self._references.get(reference_key)
+ if cached is not None:
+ return cached
+
+ path = self.resolve(reference, root)
+ source_key = os.path.normcase(str(path))
+ mapped = self._source_paths.get((kind, source_key))
+ if mapped is not None:
+ result = (mapped, path)
+ self._references[reference_key] = result
+ return result
+
+ if not path.is_file():
+ missing_key = (kind, source_key)
+ repository_path = self._missing_paths.get(missing_key)
+ if repository_path is None:
+ token = hashlib.sha256(f"{kind}\0{source_key}".encode("utf-8")).hexdigest()
+ repository_path = self._destination(
+ kind,
+ f"missing-{token}",
+ _canonical_suffix(path),
+ )
+ self._missing_paths[missing_key] = repository_path
+ self._assets[repository_path] = PlannedAsset(
+ repository_path=repository_path,
+ sha256=None,
+ bytes=0,
+ kind=kind,
+ benchmark=self.source.benchmark,
+ license=self.source.license,
+ source_path=path,
+ missing=True,
+ )
+ self._source_paths[(kind, source_key)] = repository_path
+ result = (repository_path, path)
+ self._references[reference_key] = result
+ return result
+
+ resolved = path.resolve(strict=True)
+ if not resolved.is_file():
+ raise ConversionError(f"asset source is not a regular file: {path}")
+ byte_count = resolved.stat().st_size
+ if self.hash_assets:
+ digest = sha256_file(resolved)
+ repository_path = self._destination(
+ kind,
+ digest,
+ _canonical_suffix(resolved),
+ )
+ else:
+ digest = None
+ repository_path = self._source_destination(kind, resolved, root)
+ asset = PlannedAsset(
+ repository_path=repository_path,
+ sha256=digest,
+ bytes=byte_count,
+ kind=kind,
+ benchmark=self.source.benchmark,
+ license=self.source.license,
+ source_path=resolved,
+ )
+ previous = self._assets.get(repository_path)
+ if previous is not None and (
+ previous.sha256 != asset.sha256
+ or previous.bytes != asset.bytes
+ or (
+ asset.sha256 is None
+ and previous.source_path != asset.source_path
+ )
+ ):
+ raise ConversionError(f"asset output path collision: {repository_path}")
+ self._assets[repository_path] = asset
+ self._source_paths[(kind, source_key)] = repository_path
+ self._source_paths[
+ (kind, os.path.normcase(str(resolved)))
+ ] = repository_path
+ result = (repository_path, resolved)
+ self._references[reference_key] = result
+ return result
+
+ def add_bytes(
+ self,
+ content: bytes,
+ *,
+ kind: str,
+ suffix: str = "",
+ ) -> str:
+ if self.hash_assets:
+ digest = hashlib.sha256(content).hexdigest()
+ repository_path = self._destination(kind, digest, suffix)
+ else:
+ digest = None
+ index = self._embedded_counts.get(kind, 0) + 1
+ self._embedded_counts[kind] = index
+ filename = f"{self.source.split}__embedded_{index:08d}{suffix}"
+ if kind == "artifacts":
+ directory = artifact_directory(self.source.benchmark)
+ else:
+ directory = media_directory(self.source.benchmark, kind)
+ repository_path = f"{directory}/{self.source.split}/{filename}"
+ asset = PlannedAsset(
+ repository_path=repository_path,
+ sha256=digest,
+ bytes=len(content),
+ kind=kind,
+ benchmark=self.source.benchmark,
+ license=self.source.license,
+ content=content,
+ )
+ previous = self._assets.get(repository_path)
+ if previous is not None and (
+ previous.sha256 != asset.sha256 or previous.bytes != asset.bytes
+ ):
+ raise ConversionError(f"asset output path collision: {repository_path}")
+ self._assets[repository_path] = asset
+ return repository_path
+
+
+def _kind_for_key(key: str, default: str = "artifacts") -> str:
+ folded = key.casefold()
+ if "subtitle" in folded:
+ return "subtitles"
+ if "image" in folded:
+ return "images"
+ if "video" in folded:
+ return "videos"
+ return default
+
+
+def _root_for_kind(source: EvaluationSource, kind: str, key: str = "") -> Path:
+ if kind == "artifacts":
+ return source.preprocessed_root(key)
+ return source.media_root(kind)
+
+
+def _stage_path_value(
+ value: Any,
+ *,
+ key: str,
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+ default_kind: str = "artifacts",
+ force_kind: str | None = None,
+) -> Any:
+ kind = force_kind or _kind_for_key(key, default_kind)
+ root = _root_for_kind(source, kind, key)
+ if isinstance(value, (str, os.PathLike)):
+ if _is_blank_path_reference(value):
+ return _DROP
+ local_path = planner.resolve(value, root)
+ annotation_sources = {
+ os.path.normcase(str(source.annotation)),
+ os.path.normcase(str(source.annotation.resolve())),
+ }
+ if os.path.normcase(str(local_path)) in annotation_sources:
+ return annotation_path(source.benchmark, source.split)
+ mapped = planner.mapped_path(value, root, kind=kind)
+ if mapped is not None:
+ return mapped
+ repository_path, _local = planner.add_file(value, kind=kind, root=root)
+ return repository_path
+ if isinstance(value, Mapping):
+ staged: dict[str, Any] = {}
+ for raw_key, item in value.items():
+ if not isinstance(raw_key, str) or _is_private_path(raw_key, source):
+ continue
+ converted = _stage_path_value(
+ item,
+ key=raw_key,
+ source=source,
+ planner=planner,
+ default_kind=kind,
+ force_kind=force_kind,
+ )
+ if converted is not _DROP:
+ staged[raw_key] = converted
+ return staged
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ staged_items = []
+ for item in value:
+ converted = _stage_path_value(
+ item,
+ key=key,
+ source=source,
+ planner=planner,
+ default_kind=kind,
+ force_kind=force_kind,
+ )
+ if converted is not _DROP:
+ staged_items.append(converted)
+ return staged_items
+ raise ConversionError(f"{key} must contain a local path string or list")
+
+
+def _stage_payload(
+ value: Any,
+ *,
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+ path_kind: str | None = None,
+) -> Any:
+ if isinstance(value, Mapping):
+ staged: dict[str, Any] = {}
+ for raw_key, item in value.items():
+ if not isinstance(raw_key, str) or _is_private_path(raw_key, source):
+ continue
+ if _is_path_key(raw_key):
+ converted = _stage_path_value(
+ item,
+ key=raw_key,
+ source=source,
+ planner=planner,
+ force_kind=path_kind,
+ )
+ if converted is not _DROP:
+ staged[raw_key] = converted
+ continue
+ nested = _stage_payload(
+ item,
+ source=source,
+ planner=planner,
+ path_kind=path_kind,
+ )
+ if nested is not _DROP:
+ staged[raw_key] = nested
+ return staged
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ staged_items = []
+ for item in value:
+ nested = _stage_payload(
+ item,
+ source=source,
+ planner=planner,
+ path_kind=path_kind,
+ )
+ if nested is not _DROP:
+ staged_items.append(nested)
+ return staged_items
+ return _json_safe(value, source)
+
+
+def _profile_without_paths(value: Any, source: EvaluationSource) -> Any:
+ if isinstance(value, Mapping):
+ converted: dict[str, Any] = {}
+ for raw_key, item in value.items():
+ if (
+ not isinstance(raw_key, str)
+ or _is_private_path(raw_key, source)
+ or _is_path_key(raw_key)
+ ):
+ continue
+ safe = _profile_without_paths(item, source)
+ if safe is not _DROP:
+ converted[raw_key] = safe
+ return converted
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ converted_items = []
+ for item in value:
+ safe = _profile_without_paths(item, source)
+ if safe is not _DROP:
+ converted_items.append(safe)
+ return converted_items
+ return _json_safe(value, source)
+
+
+def _structured_answer_payload(value: Any) -> Mapping[str, Any] | None:
+ if isinstance(value, Mapping):
+ return value
+ if not isinstance(value, str):
+ return None
+ matches = re.findall(
+ r"\s*(.*?)\s* ",
+ value,
+ flags=re.IGNORECASE | re.DOTALL,
+ )
+ candidate = matches[-1] if matches else value.strip()
+ if candidate.startswith("```"):
+ candidate = re.sub(r"^```(?:json)?\s*", "", candidate, flags=re.IGNORECASE)
+ candidate = re.sub(r"\s*```$", "", candidate)
+ try:
+ parsed = json.loads(candidate)
+ except json.JSONDecodeError:
+ return None
+ return parsed if isinstance(parsed, Mapping) else None
+
+
+def _stage_media_entry(
+ entry: Any,
+ *,
+ kind: str,
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+) -> tuple[Any, Path]:
+ root = source.media_root(kind)
+ if isinstance(entry, (str, os.PathLike)):
+ if _is_blank_path_reference(entry):
+ raise ConversionError(f"{kind} entry contains an empty local path")
+ repository_path, local_path = planner.add_file(entry, kind=kind, root=root)
+ return repository_path, local_path
+ if not isinstance(entry, Mapping):
+ raise ConversionError(f"{kind} entry must be a path string or JSON object")
+
+ staged: dict[str, Any] = {}
+ local_path: Path | None = None
+ for raw_key, value in entry.items():
+ if not isinstance(raw_key, str) or _is_private_path(raw_key, source):
+ continue
+ if raw_key in MEDIA_PATH_KEYS and isinstance(value, (str, os.PathLike)):
+ if _is_blank_path_reference(value):
+ continue
+ repository_path, candidate = planner.add_file(value, kind=kind, root=root)
+ staged[raw_key] = repository_path
+ if local_path is None:
+ local_path = candidate
+ else:
+ safe = _json_safe(value, source)
+ if safe is not _DROP:
+ staged[raw_key] = safe
+ if local_path is None:
+ raise ConversionError(f"{kind} entry does not contain a local path")
+ return staged, local_path
+
+
+def _media_inputs(record: Mapping[str, Any], source: EvaluationSource) -> tuple[Any, Any]:
+ images = _first(record, ("images", "image", "image_path"))
+ videos = _first(record, ("videos", "video", "video_path"))
+ generic = _first(record, ("path", "media_path", "media", "file_name"))
+ if source.eval_task == "mvbench" and generic is not None:
+ # MVBench keeps the upstream WebM filename in ``video`` while ``path``
+ # points to the materialized MP4 used by its evaluator.
+ videos = generic
+ if generic is not None and images is None and videos is None:
+ values = _as_list(generic)
+ suffixes = {
+ Path(str(value)).suffix.casefold()
+ for value in values
+ if isinstance(value, (str, os.PathLike))
+ }
+ data_type = str(record.get("data_type") or "").casefold()
+ family = source.family.casefold()
+ if suffixes and suffixes <= _IMAGE_SUFFIXES:
+ images = generic
+ elif (
+ "image" in data_type
+ or source.adapter == "spatial_grounding"
+ or ("spatial_grounding" in family and "temporal" not in family)
+ ):
+ images = generic
+ else:
+ videos = generic
+ return images, videos
+
+
+def _stage_media(
+ record: Mapping[str, Any],
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+) -> tuple[list[Any], list[Any], list[Path]]:
+ raw_images, raw_videos = _media_inputs(record, source)
+ images: list[Any] = []
+ videos: list[Any] = []
+ video_sources: list[Path] = []
+ for entry in _as_list(raw_images):
+ if _is_blank_path_reference(entry):
+ continue
+ staged, _local = _stage_media_entry(
+ entry,
+ kind="images",
+ source=source,
+ planner=planner,
+ )
+ images.append(staged)
+ for entry in _as_list(raw_videos):
+ if _is_blank_path_reference(entry):
+ continue
+ staged, local = _stage_media_entry(
+ entry,
+ kind="videos",
+ source=source,
+ planner=planner,
+ )
+ videos.append(staged)
+ video_sources.append(local)
+ return images, videos, video_sources
+
+
+def _subtitle_input(record: Mapping[str, Any], source: EvaluationSource) -> tuple[Any, bool]:
+ explicit = _first(record, ("subtitles", "subtitle_path", "subtitle_file"))
+ if explicit is not None:
+ return explicit, True
+ subtitle = record.get("subtitle")
+ if subtitle is None:
+ return None, False
+ values = _as_list(subtitle)
+ if all(
+ isinstance(value, str)
+ and (
+ Path(value).suffix.casefold() in _SUBTITLE_SUFFIXES
+ or source.media_root("subtitles").joinpath(value).is_file()
+ )
+ for value in values
+ ):
+ return subtitle, True
+ return None, False
+
+
+def _derived_files(
+ source_paths: Sequence[Path],
+ root: Path,
+ suffixes: Sequence[str],
+ video_root: Path | None = None,
+) -> list[Path]:
+ derived: list[Path] = []
+ seen: set[str] = set()
+ for source_path in source_paths:
+ relative = Path(source_path.name)
+ if video_root is not None:
+ try:
+ relative = source_path.relative_to(video_root)
+ except ValueError:
+ pass
+ found = False
+ for suffix in suffixes:
+ candidates = (
+ root / relative.with_suffix(suffix),
+ root / f"{source_path.stem}{suffix}",
+ )
+ for candidate in candidates:
+ if not candidate.is_file():
+ continue
+ key = os.path.normcase(str(candidate.resolve()))
+ if key not in seen:
+ seen.add(key)
+ derived.append(candidate)
+ found = True
+ break
+ if found:
+ break
+ return derived
+
+
+def _stage_subtitles(
+ record: Mapping[str, Any],
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+ video_sources: Sequence[Path],
+) -> tuple[list[Any], bool]:
+ raw, consumed_subtitle = _subtitle_input(record, source)
+ subtitles: list[Any] = []
+ if raw is not None:
+ for entry in _as_list(raw):
+ if _is_blank_path_reference(entry):
+ continue
+ staged, _local = _stage_media_entry(
+ entry,
+ kind="subtitles",
+ source=source,
+ planner=planner,
+ )
+ subtitles.append(staged)
+ elif "subtitles" in source.media_roots:
+ for path in _derived_files(
+ video_sources,
+ source.media_roots["subtitles"],
+ _SUBTITLE_SUFFIXES,
+ source.media_roots.get("videos", source.media_roots.get("default")),
+ ):
+ staged, _local = _stage_media_entry(
+ path,
+ kind="subtitles",
+ source=source,
+ planner=planner,
+ )
+ subtitles.append(staged)
+ return subtitles, consumed_subtitle
+
+
+def _stage_preprocessed(
+ record: Mapping[str, Any],
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+ video_sources: Sequence[Path],
+) -> dict[str, Any]:
+ raw = record.get("preprocessed")
+ staged: dict[str, Any] = {}
+ if raw is not None:
+ if isinstance(raw, Mapping):
+ converted = _stage_payload(
+ raw,
+ source=source,
+ planner=planner,
+ path_kind="artifacts",
+ )
+ if isinstance(converted, Mapping):
+ staged.update(converted)
+ elif isinstance(raw, (str, os.PathLike)):
+ converted = _stage_path_value(
+ raw,
+ key="path",
+ source=source,
+ planner=planner,
+ force_kind="artifacts",
+ )
+ if converted is not _DROP:
+ staged["path"] = converted
+ else:
+ raise ConversionError("preprocessed must be a JSON object or local path")
+
+ explicit_fields = (
+ "artifact_path",
+ "preprocessed_path",
+ "preprocessed_video",
+ "preprocessed_video_path",
+ "tensor_path",
+ )
+ for field in explicit_fields:
+ if record.get(field) is not None:
+ converted = _stage_path_value(
+ record[field],
+ key=field,
+ source=source,
+ planner=planner,
+ force_kind="artifacts",
+ )
+ if converted is not _DROP:
+ staged[field] = converted
+
+ if not any(_is_path_key(key) for key in staged) and source.preprocessed_roots:
+ root = source.preprocessed_root()
+ derived = _derived_files(
+ video_sources,
+ root,
+ _ARTIFACT_SUFFIXES,
+ source.media_roots.get("videos", source.media_roots.get("default")),
+ )
+ if len(derived) == 1:
+ staged["video_path"] = _stage_path_value(
+ derived[0],
+ key="video_path",
+ source=source,
+ planner=planner,
+ force_kind="artifacts",
+ )
+ elif derived:
+ staged["video_paths"] = _stage_path_value(
+ derived,
+ key="video_paths",
+ source=source,
+ planner=planner,
+ force_kind="artifacts",
+ )
+ return staged
+
+
+def _choices(value: Any, source: EvaluationSource) -> list[str]:
+ if isinstance(value, Mapping):
+ value = [value[key] for key in sorted(value, key=str)]
+ choices = []
+ for item in _as_list(value):
+ safe = _json_safe(item, source)
+ if safe is _DROP:
+ continue
+ text = str(safe).strip()
+ if text:
+ choices.append(text)
+ return choices
+
+
+def _sample_id(record: Mapping[str, Any], source: EvaluationSource, index: int) -> str:
+ value = _first(
+ record,
+ ("sample_id", "question_id", "problem_id", "id", "uid", "index"),
+ )
+ if value is None:
+ return f"{source.benchmark}-{index + 1:08d}"
+ text = str(value).strip()
+ if not text:
+ raise ConversionError(f"source row {index + 1}: sample id must be nonempty")
+ if _is_private_path(text, source):
+ return f"{source.benchmark}-{index + 1:08d}"
+ return text
+
+
+def _canonical_row(
+ record: Mapping[str, Any],
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+ index: int,
+ *,
+ images_override: list[Any] | None = None,
+ videos_override: list[Any] | None = None,
+) -> dict[str, Any]:
+ if source.adapter == "spatial_grounding":
+ problem_keys = (
+ "expression",
+ "normal_caption",
+ "caption",
+ "query",
+ "problem",
+ "question",
+ "prompt",
+ "input_prompt",
+ )
+ answer_keys = (
+ "normalized_solution",
+ "bbox",
+ _GROUND_TRUTH_BBOX_ALIAS,
+ "solution",
+ "answer",
+ "answers",
+ "ground_truth",
+ "oracle_label",
+ "oracle_labels",
+ _GROUND_TRUTH_ANSWER_ALIAS,
+ )
+ elif source.eval_task == "mvbench":
+ problem_keys = (
+ "problem",
+ "question",
+ "prompt",
+ "input_prompt",
+ )
+ answer_keys = (
+ "solution",
+ "answer",
+ "ground_truth",
+ _GROUND_TRUTH_ANSWER_ALIAS,
+ )
+ else:
+ problem_keys = (
+ "problem",
+ "question",
+ "prompt",
+ "input_prompt",
+ "expression",
+ "normal_caption",
+ "caption",
+ "query",
+ )
+ answer_keys = (
+ "answer",
+ "answers",
+ "ground_truth",
+ "oracle_label",
+ "oracle_labels",
+ "solution",
+ _GROUND_TRUTH_ANSWER_ALIAS,
+ "bbox",
+ "normalized_solution",
+ _GROUND_TRUTH_BBOX_ALIAS,
+ "span",
+ )
+ problem = _first(
+ record,
+ problem_keys,
+ )
+ answer = _first(
+ record,
+ answer_keys,
+ )
+ if source.eval_task == "mvbench" and isinstance(answer, str):
+ tagged_answer = re.fullmatch(
+ r"\s*\s*([A-Za-z])\s* \s*",
+ answer,
+ flags=re.IGNORECASE,
+ )
+ if tagged_answer is not None:
+ answer = tagged_answer.group(1).upper()
+ if problem is None:
+ problem = _message_content(record, "user")
+ if answer is None:
+ answer = _message_content(record, "assistant", reverse=True)
+ safe_problem = _json_safe(problem, source)
+ safe_answer = _json_safe(answer, source)
+ if safe_problem is _DROP:
+ safe_problem = None
+ if safe_answer is _DROP:
+ safe_answer = None
+ if (
+ source.eval_task == "segmentation"
+ and isinstance(safe_answer, str)
+ and not safe_answer.strip()
+ and isinstance(record.get("segmentation_output"), Mapping)
+ ):
+ safe_answer = None
+
+ if images_override is None or videos_override is None:
+ staged_images, staged_videos, video_sources = _stage_media(record, source, planner)
+ images = staged_images if images_override is None else images_override
+ videos = staged_videos if videos_override is None else videos_override
+ else:
+ images = images_override
+ videos = videos_override
+ video_sources = []
+
+ subtitles, consumed_subtitle = _stage_subtitles(
+ record,
+ source,
+ planner,
+ video_sources,
+ )
+ preprocessed = _stage_preprocessed(record, source, planner, video_sources)
+
+ task_payload: dict[str, Any] = {}
+ parsed_answer = _structured_answer_payload(answer)
+ if parsed_answer is not None:
+ staged_answer = _stage_payload(
+ parsed_answer,
+ source=source,
+ planner=planner,
+ )
+ if isinstance(staged_answer, Mapping):
+ task_payload.update(staged_answer)
+ raw_payload = record.get("task_payload")
+ if isinstance(raw_payload, Mapping):
+ staged_payload = _stage_payload(raw_payload, source=source, planner=planner)
+ if isinstance(staged_payload, Mapping):
+ task_payload.update(staged_payload)
+ metadata: dict[str, Any] = {}
+ raw_metadata = record.get("metadata")
+ if isinstance(raw_metadata, Mapping):
+ staged_metadata = _stage_payload(raw_metadata, source=source, planner=planner)
+ if isinstance(staged_metadata, Mapping):
+ metadata.update(staged_metadata)
+
+ consumed = set(_BASE_CONSUMED_FIELDS)
+ consumed.update(
+ {
+ "artifact_path",
+ "preprocessed_path",
+ "preprocessed_video",
+ "preprocessed_video_path",
+ "tensor_path",
+ }
+ )
+ if consumed_subtitle:
+ consumed.add("subtitle")
+ for identifier in (
+ "question_id",
+ "problem_id",
+ "id",
+ "uid",
+ "index",
+ "data_type",
+ "input_prompt",
+ "question_type",
+ "task_type",
+ ):
+ if record.get(identifier) is not None:
+ safe = _json_safe(record[identifier], source)
+ if safe is not _DROP:
+ metadata.setdefault(identifier, safe)
+
+ for raw_key, value in record.items():
+ if (
+ not isinstance(raw_key, str)
+ or _is_private_path(raw_key, source)
+ or raw_key in consumed
+ ):
+ continue
+ if raw_key in _PAYLOAD_KEYS or _is_path_key(raw_key):
+ staged = (
+ _stage_path_value(
+ value,
+ key=raw_key,
+ source=source,
+ planner=planner,
+ )
+ if _is_path_key(raw_key)
+ else _stage_payload(value, source=source, planner=planner)
+ )
+ if staged is not _DROP:
+ task_payload.setdefault(raw_key, staged)
+ else:
+ safe = _stage_payload(value, source=source, planner=planner)
+ if safe is not _DROP:
+ metadata.setdefault(raw_key, safe)
+
+ row_evaluation = record.get("evaluation")
+ evaluation = _profile_without_paths(source.evaluation, source)
+ if not isinstance(evaluation, dict):
+ evaluation = {}
+ if isinstance(row_evaluation, Mapping):
+ safe_evaluation = _profile_without_paths(row_evaluation, source)
+ if isinstance(safe_evaluation, Mapping):
+ evaluation.update(safe_evaluation)
+
+ source_name = _first(record, ("source", "data_source", "dataset", "bench"))
+ safe_source_name = _json_safe(source_name, source)
+ if safe_source_name is _DROP or not str(safe_source_name or "").strip():
+ safe_source_name = source.benchmark
+ problem_type = _first(record, ("problem_type", "question_type", "task_type"))
+ if (
+ not isinstance(problem_type, str)
+ or not problem_type.strip()
+ or _is_private_path(problem_type, source)
+ ):
+ problem_type = source.eval_task
+
+ row: dict[str, Any] = {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "eval_task": source.eval_task,
+ "sample_id": _sample_id(record, source, index),
+ "benchmark": source.benchmark,
+ "split": source.split,
+ "problem": safe_problem,
+ "answer": safe_answer,
+ "images": images,
+ "videos": videos,
+ "problem_type": problem_type,
+ "source": str(safe_source_name),
+ "family": source.family,
+ "task_payload": task_payload,
+ "evaluation": evaluation,
+ }
+ row_choices = _choices(_first(record, ("choices", "options")), source)
+ if row_choices:
+ row["choices"] = row_choices
+ if subtitles:
+ row["subtitles"] = subtitles
+ if preprocessed:
+ row["preprocessed"] = preprocessed
+ if metadata:
+ row["metadata"] = metadata
+ return row
+
+
+def _generic_records(
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+) -> tuple[list[dict[str, Any]], int]:
+ try:
+ records = read_records(source.annotation_input)
+ except (OSError, ValueError) as error:
+ raise ConversionError(f"{source.annotation_input}: {error}") from error
+ if len(records) == 1:
+ container = records[0]
+ for field in ("annotations", "items", "questions"):
+ nested = container.get(field)
+ if isinstance(nested, list) and all(isinstance(item, Mapping) for item in nested):
+ records = [dict(item) for item in nested]
+ break
+ else:
+ row_fields = {
+ "problem",
+ "question",
+ "prompt",
+ "expression",
+ "normal_caption",
+ "caption",
+ }
+ if (
+ container
+ and not row_fields.intersection(container)
+ and all(isinstance(item, Mapping) for item in container.values())
+ ):
+ flattened = []
+ for key in sorted(container, key=str):
+ item = dict(container[key])
+ item.setdefault("id", key)
+ flattened.append(item)
+ records = flattened
+ rows = []
+ for index, record in enumerate(records):
+ try:
+ rows.append(_canonical_row(record, source, planner, index))
+ except ConversionError as error:
+ raise ConversionError(f"source row {index + 1}: {error}") from error
+ return rows, len(records)
+
+
+def _decode_base64_images(value: Any, *, context: str) -> list[bytes]:
+ if isinstance(value, str):
+ try:
+ parsed = ast.literal_eval(value)
+ except (SyntaxError, ValueError):
+ parsed = value
+ else:
+ parsed = value
+ values = _as_list(parsed)
+ images: list[bytes] = []
+ for index, item in enumerate(values):
+ if item is None or item == "":
+ continue
+ if isinstance(item, (bytes, bytearray, memoryview)):
+ images.append(bytes(item))
+ continue
+ encoded = str(item).strip()
+ if encoded.startswith("data:") and "," in encoded:
+ encoded = encoded.split(",", 1)[1]
+ try:
+ decoded = base64.b64decode(encoded, validate=True)
+ except (binascii.Error, ValueError) as error:
+ raise ConversionError(f"{context}: image {index} is not valid base64") from error
+ if not decoded:
+ raise ConversionError(f"{context}: image {index} decoded to empty bytes")
+ images.append(decoded)
+ return images
+
+
+def _mmsi_records(
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+) -> tuple[list[dict[str, Any]], int]:
+ csv.field_size_limit(sys.maxsize)
+ try:
+ with source.annotation_input.open(
+ "r",
+ encoding="utf-8",
+ errors="replace",
+ newline="",
+ ) as handle:
+ records = list(csv.DictReader(handle, delimiter="\t"))
+ except (csv.Error, OSError) as error:
+ raise ConversionError(f"{source.annotation_input}: invalid MMSI TSV: {error}") from error
+ if not records and source.expected_count:
+ raise ConversionError(f"{source.annotation_input}: MMSI TSV has no source rows")
+
+ rows = []
+ for index, record in enumerate(records):
+ image_bytes = _decode_base64_images(
+ record.get("image"),
+ context=f"{source.annotation_input}:{index + 2}",
+ )
+ image_paths = [
+ planner.add_bytes(content, kind="images", suffix=_image_suffix(content))
+ for content in image_bytes
+ ]
+ prepared = dict(record)
+ prepared.pop("image", None)
+ rows.append(
+ _canonical_row(
+ prepared,
+ source,
+ planner,
+ index,
+ images_override=image_paths,
+ videos_override=[],
+ )
+ )
+ return rows, len(records)
+
+
+def _load_parquet(source: EvaluationSource) -> list[dict[str, Any]]:
+ pandas_error: Exception | None = None
+ try:
+ import pandas as pd
+ except ImportError as error:
+ pandas_error = error
+ else:
+ try:
+ return pd.read_parquet(source.annotation_input).to_dict("records")
+ except ImportError as error:
+ pandas_error = error
+
+ try:
+ import pyarrow.parquet as parquet
+ except ImportError as error:
+ dependency = "pandas with a parquet engine or pyarrow"
+ raise ConversionError(f"parquet conversion requires {dependency}") from (
+ pandas_error or error
+ )
+ try:
+ return parquet.read_table(source.annotation_input).to_pylist()
+ except (OSError, ValueError) as error:
+ raise ConversionError(f"{source.annotation_input}: invalid parquet: {error}") from error
+
+
+def _validate_official_revsi(
+ records: Sequence[Mapping[str, Any]],
+ source: EvaluationSource,
+) -> None:
+ expected_total = sum(_REVSI_EXPECTED_GROUP_COUNTS.values())
+ if source.expected_count != expected_total:
+ return
+ counts = {group: 0 for group in _REVSI_EXPECTED_GROUP_COUNTS}
+ unknown = 0
+ for record in records:
+ question_type = str(record.get("question_type") or "").strip()
+ group = next(
+ (
+ prefix
+ for prefix in (
+ "object_counting",
+ "object_rel_direction",
+ "object_rel_distance",
+ "room_size_estimation",
+ )
+ if question_type.startswith(prefix)
+ ),
+ question_type,
+ )
+ if group in counts:
+ counts[group] += 1
+ else:
+ unknown += 1
+ if counts != _REVSI_EXPECTED_GROUP_COUNTS or unknown:
+ observed = {**counts, **({"unknown": unknown} if unknown else {})}
+ raise ConversionError(
+ "official ReVSI all-frame split must contain "
+ f"{_REVSI_EXPECTED_GROUP_COUNTS}, got {observed}"
+ )
+
+
+def _revsi_records(
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+) -> tuple[list[dict[str, Any]], int]:
+ records = _load_parquet(source)
+ _validate_official_revsi(records, source)
+ video_root = source.media_root("videos")
+ rows = []
+ for index, record in enumerate(records):
+ if not isinstance(record, Mapping):
+ raise ConversionError(
+ f"{source.annotation_input}: parquet row {index} is not an object"
+ )
+ scene_id = str(record.get("scene_id") or "").strip()
+ video_reference = _first(record, ("video_path", "video", "path"))
+ if video_reference is None:
+ if not scene_id:
+ raise ConversionError(
+ f"{source.annotation_input}: parquet row {index} has no scene_id"
+ )
+ video_reference = f"{scene_id}.mp4"
+ video_path, _local_path = planner.add_file(
+ video_reference,
+ kind="videos",
+ root=video_root,
+ )
+ rows.append(
+ _canonical_row(
+ record,
+ source,
+ planner,
+ index,
+ images_override=[],
+ videos_override=[video_path],
+ )
+ )
+ return rows, len(records)
+
+
+def _mindcube_image_values(record: Mapping[str, Any]) -> list[Any]:
+ values = record.get("images")
+ if values is not None:
+ return _as_list(values)
+ fields = sorted(
+ (
+ key
+ for key in record
+ if isinstance(key, str) and re.fullmatch(r"image_?\d+", key)
+ ),
+ key=lambda key: int(re.search(r"\d+", key).group()) if re.search(r"\d+", key) else 0,
+ )
+ return [record[field] for field in fields]
+
+
+def _mindcube_image(
+ value: Any,
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+) -> str | None:
+ if value is None:
+ return None
+ if isinstance(value, Mapping):
+ content = value.get("bytes")
+ if isinstance(content, (bytes, bytearray, memoryview)):
+ raw = bytes(content)
+ return planner.add_bytes(raw, kind="images", suffix=_image_suffix(raw))
+ path = value.get("path")
+ if path:
+ repository_path, _local = planner.add_file(
+ path,
+ kind="images",
+ root=source.media_root("images"),
+ )
+ return repository_path
+ return None
+ if isinstance(value, (bytes, bytearray, memoryview)):
+ raw = bytes(value)
+ return planner.add_bytes(raw, kind="images", suffix=_image_suffix(raw))
+ if isinstance(value, (str, os.PathLike)):
+ repository_path, _local = planner.add_file(
+ value,
+ kind="images",
+ root=source.media_root("images"),
+ )
+ return repository_path
+ save = getattr(value, "save", None)
+ if callable(save):
+ buffer = io.BytesIO()
+ save(buffer, format="PNG")
+ return planner.add_bytes(buffer.getvalue(), kind="images", suffix=".png")
+ return None
+
+
+def _mindcube_group(record: Mapping[str, Any]) -> str:
+ for key in ("task", "setting"):
+ value = str(record.get(key) or "").strip().casefold()
+ if value in _MINDCUBE_EXPECTED_GROUP_COUNTS:
+ return value
+ sample_id = str(
+ _first(record, ("id", "index", "sample_id")) or ""
+ ).strip().casefold()
+ match = re.match(r"^(rotation|among|around)(?:_|$)", sample_id)
+ return match.group(1) if match else "unknown"
+
+
+def _validate_official_mindcube(
+ records: Sequence[Mapping[str, Any]],
+ source: EvaluationSource,
+) -> None:
+ expected_total = sum(_MINDCUBE_EXPECTED_GROUP_COUNTS.values())
+ if source.expected_count != expected_total:
+ return
+ counts = {group: 0 for group in _MINDCUBE_EXPECTED_GROUP_COUNTS}
+ unknown = 0
+ for record in records:
+ group = _mindcube_group(record)
+ if group in counts:
+ counts[group] += 1
+ else:
+ unknown += 1
+ if counts != _MINDCUBE_EXPECTED_GROUP_COUNTS or unknown:
+ observed = {**counts, **({"unknown": unknown} if unknown else {})}
+ raise ConversionError(
+ "official MindCube-Tiny must contain "
+ f"{_MINDCUBE_EXPECTED_GROUP_COUNTS}, got {observed}"
+ )
+
+
+def _mindcube_records(
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+) -> tuple[list[dict[str, Any]], int]:
+ records = _load_parquet(source)
+ _validate_official_mindcube(records, source)
+ rows = []
+ for index, record in enumerate(records):
+ if not isinstance(record, Mapping):
+ raise ConversionError(
+ f"{source.annotation_input}: parquet row {index} is not an object"
+ )
+ image_paths = [
+ path
+ for value in _mindcube_image_values(record)
+ if (path := _mindcube_image(value, source, planner)) is not None
+ ]
+ prepared = {
+ key: value
+ for key, value in record.items()
+ if key != "images"
+ and not (isinstance(key, str) and re.fullmatch(r"image_?\d+", key))
+ }
+ rows.append(
+ _canonical_row(
+ prepared,
+ source,
+ planner,
+ index,
+ images_override=image_paths,
+ videos_override=[],
+ )
+ )
+ return rows, len(records)
+
+
+def _timelens_records(
+ source: EvaluationSource,
+ planner: _AssetPlanner,
+) -> tuple[list[dict[str, Any]], int]:
+ try:
+ with source.annotation_input.open("r", encoding="utf-8") as handle:
+ payload = json.load(handle)
+ except (json.JSONDecodeError, OSError) as error:
+ raise ConversionError(
+ f"{source.annotation_input}: invalid TimeLens JSON: {error}"
+ ) from error
+
+ if isinstance(payload, Mapping):
+ nested = [(str(video_id), info) for video_id, info in payload.items()]
+ elif isinstance(payload, list):
+ nested = []
+ for index, info in enumerate(payload):
+ if not isinstance(info, Mapping):
+ raise ConversionError(
+ f"{source.annotation_input}: TimeLens entry {index} is not an object"
+ )
+ video_id = _first(info, ("video_id", "video", "id", "name"))
+ nested.append((str(video_id if video_id is not None else index), info))
+ else:
+ raise ConversionError(f"{source.annotation_input}: TimeLens JSON must be an object or list")
+
+ rows: list[dict[str, Any]] = []
+ for video_id, raw_info in nested:
+ if not isinstance(raw_info, Mapping):
+ raise ConversionError(
+ f"{source.annotation_input}: TimeLens entry {video_id!r} is not an object"
+ )
+ queries = _as_list(_first(raw_info, ("queries", "sentences")))
+ spans = _as_list(_first(raw_info, ("spans", "timestamps")))
+ if (
+ len(queries) == 1
+ and len(spans) == 2
+ and all(isinstance(value, (int, float)) for value in spans)
+ ):
+ spans = [spans]
+ if len(queries) != len(spans):
+ raise ConversionError(
+ f"{source.annotation_input}: TimeLens entry {video_id!r} has "
+ f"{len(queries)} queries but {len(spans)} spans"
+ )
+ video_reference = _first(raw_info, ("video_path", "path", "video"))
+ if video_reference is None or str(video_reference) == video_id:
+ video_reference = f"{video_id}.mp4"
+
+ common_metadata = {
+ key: value
+ for key, value in raw_info.items()
+ if key
+ not in {
+ "queries",
+ "sentences",
+ "spans",
+ "timestamps",
+ "video",
+ "video_path",
+ "path",
+ }
+ }
+ for query_index, (query, span) in enumerate(zip(queries, spans)):
+ query_text = re.sub(r"\s+", " ", str(query)).strip().strip(".")
+ prepared = {
+ "sample_id": f"{video_id}:{query_index:06d}",
+ "problem": query_text,
+ "answer": span,
+ "videos": [video_reference],
+ "problem_type": source.eval_task,
+ "source": source.benchmark,
+ "task_payload": {
+ "span": span,
+ **(
+ {"duration": raw_info["duration"]}
+ if raw_info.get("duration") is not None
+ else {}
+ ),
+ },
+ "metadata": {
+ "video_id": video_id,
+ "query_index": query_index,
+ **common_metadata,
+ },
+ }
+ rows.append(_canonical_row(prepared, source, planner, len(rows)))
+ return rows, len(nested)
+
+
+def convert_source(
+ source: EvaluationSource,
+ *,
+ hash_assets: bool = False,
+) -> ConvertedSource:
+ """Convert one resolved source record without writing any files."""
+
+ planner = _AssetPlanner(source, hash_assets=hash_assets)
+ try:
+ if source.adapter in GENERIC_ADAPTERS:
+ rows, source_rows = _generic_records(source, planner)
+ elif source.adapter == "mmsi":
+ rows, source_rows = _mmsi_records(source, planner)
+ elif source.adapter == "mindcube":
+ rows, source_rows = _mindcube_records(source, planner)
+ elif source.adapter == "revsi":
+ rows, source_rows = _revsi_records(source, planner)
+ elif source.adapter == "timelens":
+ rows, source_rows = _timelens_records(source, planner)
+ else:
+ raise ConversionError(f"unsupported adapter: {source.adapter}")
+
+ rows = sorted(rows, key=lambda row: (str(row["sample_id"]), _json_bytes(row)))
+ validate_evaluation_rows(
+ rows,
+ benchmark=source.benchmark,
+ split=source.split,
+ eval_task=source.eval_task,
+ context=f"{source.benchmark}/{source.split}",
+ )
+ except EvaluationSchemaError as error:
+ raise ConversionError(
+ f"{source.benchmark}/{source.split}: {error}"
+ ) from error
+ except ConversionError as error:
+ raise ConversionError(
+ f"{source.benchmark}/{source.split}: {error}"
+ ) from error
+ except (TypeError, ValueError) as error:
+ raise ConversionError(
+ f"{source.benchmark}/{source.split}: conversion failed: {error}"
+ ) from error
+ return ConvertedSource(
+ source=source,
+ rows=tuple(rows),
+ assets=planner.assets,
+ source_rows=source_rows,
+ )
+
+
+def convert_sources(
+ sources: Sequence[EvaluationSource],
+ *,
+ workers: int = 1,
+ hash_assets: bool = False,
+) -> list[ConvertedSource]:
+ """Convert sources deterministically, optionally in parallel by split."""
+
+ if isinstance(workers, bool) or not isinstance(workers, int) or workers <= 0:
+ raise ConversionError("workers must be a positive integer")
+ ordered = sorted(sources, key=lambda item: (item.benchmark, item.split))
+ if workers == 1 or len(ordered) <= 1:
+ return [
+ convert_source(source, hash_assets=hash_assets)
+ for source in ordered
+ ]
+ with ThreadPoolExecutor(
+ max_workers=min(workers, len(ordered)),
+ thread_name_prefix="orarl-eval",
+ ) as executor:
+ return list(
+ executor.map(
+ lambda source: convert_source(
+ source,
+ hash_assets=hash_assets,
+ ),
+ ordered,
+ )
+ )
+
+
+__all__ = [
+ "ConversionError",
+ "ConvertedSource",
+ "PlannedAsset",
+ "convert_source",
+ "convert_sources",
+]
diff --git a/code/orarl/evaluation/hub.py b/code/orarl/evaluation/hub.py
new file mode 100644
index 0000000000000000000000000000000000000000..c2e81a1494d8ec442ffc1ec88a35315070663af2
--- /dev/null
+++ b/code/orarl/evaluation/hub.py
@@ -0,0 +1,78 @@
+"""Explicit, validated Hugging Face publication for evaluation repositories."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+from .staging import validate_staged_repository
+
+
+class UploadError(ValueError):
+ """Raised when an evaluation repository cannot be uploaded safely."""
+
+
+def upload_evaluation_repository(
+ repository_root: str | os.PathLike[str],
+ repo_id: str,
+ *,
+ revision: str = "main",
+ private: bool = False,
+ num_workers: int | None = None,
+ checksums: bool = False,
+) -> dict[str, Any]:
+ """Validate and resumably upload a complete dataset repository.
+
+ Authentication is intentionally delegated to the Hugging Face environment
+ or persisted CLI login. This API has no token parameter.
+ """
+
+ root = Path(repository_root).expanduser().resolve()
+ validated = validate_staged_repository(root, checksums=checksums)
+ if not isinstance(repo_id, str) or not repo_id.strip() or repo_id != repo_id.strip():
+ raise UploadError("repo_id must be a nonempty Hugging Face repository id")
+ if any(character.isspace() for character in repo_id):
+ raise UploadError("repo_id must not contain whitespace")
+ if not isinstance(revision, str) or not revision.strip() or revision != revision.strip():
+ raise UploadError("revision must be a nonempty branch or revision name")
+ if not isinstance(private, bool):
+ raise UploadError("private must be a boolean")
+ if num_workers is not None and (
+ isinstance(num_workers, bool) or not isinstance(num_workers, int) or num_workers <= 0
+ ):
+ raise UploadError("num_workers must be a positive integer")
+
+ try:
+ from huggingface_hub import HfApi
+ except ImportError as error:
+ raise UploadError(
+ "upload requires huggingface_hub; install the 'hf' optional dependency"
+ ) from error
+
+ arguments: dict[str, Any] = {
+ "repo_id": repo_id,
+ "folder_path": str(root),
+ "repo_type": "dataset",
+ "revision": revision,
+ "private": private,
+ }
+ if num_workers is not None:
+ arguments["num_workers"] = num_workers
+
+ try:
+ HfApi().upload_large_folder(**arguments)
+ except Exception as error:
+ raise UploadError(f"Hugging Face upload failed ({type(error).__name__})") from error
+ return {
+ **validated,
+ "repo_id": repo_id,
+ "revision": revision,
+ "private": private,
+ }
+
+
+upload_repository = upload_evaluation_repository
+
+
+__all__ = ["UploadError", "upload_evaluation_repository", "upload_repository"]
diff --git a/code/orarl/evaluation/layout.py b/code/orarl/evaluation/layout.py
new file mode 100644
index 0000000000000000000000000000000000000000..a5871005db48265ec0b1790c43fe0d1c5ff9693f
--- /dev/null
+++ b/code/orarl/evaluation/layout.py
@@ -0,0 +1,286 @@
+"""Canonical repository layout and asset checks for OraRL evaluation data."""
+
+from __future__ import annotations
+
+import hashlib
+import re
+from collections.abc import Iterable, Mapping
+from pathlib import Path, PurePosixPath
+from typing import Union
+
+MANIFEST_FILENAME = "datasets.jsonl"
+ASSET_MANIFEST_FILENAME = "assets.jsonl"
+ANNOTATIONS_DIRECTORY = "annotations"
+MEDIA_DIRECTORY = "media"
+ARTIFACTS_DIRECTORY = "artifacts"
+MEDIA_KINDS = frozenset({"images", "videos", "subtitles"})
+BENCHMARK_GROUPS = {
+ "videomme": "video_qa",
+ "videommev2": "video_qa",
+ "mvbench": "video_qa",
+ "mmvu": "video_qa",
+ "videoholmes": "video_qa",
+ "longvideobench": "video_qa",
+ "mlvu": "video_qa",
+ "vsi": "spatial_intelligence",
+ "mmsi": "spatial_intelligence",
+ "mindcube": "spatial_intelligence",
+ "revsi": "spatial_intelligence",
+}
+
+_DATASET_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
+_URI_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
+_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
+
+
+class LayoutError(ValueError):
+ """Raised when an evaluation data path or identifier is not canonical."""
+
+
+def is_dataset_id(value: object) -> bool:
+ """Return whether ``value`` is a lowercase snake_case dataset identifier."""
+
+ return isinstance(value, str) and bool(_DATASET_ID_RE.fullmatch(value))
+
+
+def validate_dataset_id(value: object, *, context: str = "dataset id") -> str:
+ """Validate and return one lowercase snake_case dataset identifier."""
+
+ if not is_dataset_id(value):
+ raise LayoutError(f"{context} must be a lowercase snake_case identifier")
+ return value
+
+
+def validate_repository_path(value: object, *, context: str = "path") -> str:
+ """Validate and return a normalized repository-relative POSIX path.
+
+ Canonical paths are already normalized when stored. In particular, this
+ rejects URL/URI references, absolute paths, Windows separators, empty or
+ dot components, and parent traversal.
+ """
+
+ if not isinstance(value, str) or not value:
+ raise LayoutError(f"{context} must be a nonempty repository-relative POSIX path")
+ if value != value.strip() or any(ord(character) < 32 for character in value):
+ raise LayoutError(f"{context} must not contain surrounding whitespace or controls")
+ if "\\" in value:
+ raise LayoutError(f"{context} must use POSIX '/' separators, not backslashes")
+ if _URI_SCHEME_RE.match(value):
+ raise LayoutError(f"{context} must not be an absolute path or URL: {value}")
+ if value.startswith("/") or PurePosixPath(value).is_absolute():
+ raise LayoutError(f"{context} must be repository-relative, not absolute: {value}")
+ if value == "~" or value.startswith("~/"):
+ raise LayoutError(f"{context} must be repository-relative: {value}")
+
+ parts = value.split("/")
+ if ".." in parts:
+ raise LayoutError(f"{context} must not contain '..': {value}")
+ if "." in parts or "" in parts:
+ raise LayoutError(f"{context} must be normalized without '.' or empty components")
+ return value
+
+
+def benchmark_directory(benchmark: str) -> str:
+ """Return the canonical directory namespace for one benchmark."""
+
+ benchmark_id = validate_dataset_id(benchmark, context="benchmark")
+ group = BENCHMARK_GROUPS.get(benchmark_id)
+ if group is None:
+ return benchmark_id
+ group_id = validate_dataset_id(group, context="benchmark group")
+ return f"{group_id}/{benchmark_id}"
+
+
+def annotation_path(benchmark: str, split: str) -> str:
+ """Return the canonical annotation JSONL path for a benchmark split."""
+
+ directory = benchmark_directory(benchmark)
+ split_id = validate_dataset_id(split, context="split")
+ return f"{ANNOTATIONS_DIRECTORY}/{directory}/{split_id}.jsonl"
+
+
+def media_directory(benchmark: str, kind: str | None = None) -> str:
+ """Return the canonical media directory for a benchmark and optional kind."""
+
+ base = f"{MEDIA_DIRECTORY}/{benchmark_directory(benchmark)}"
+ if kind is None:
+ return base
+ kind_id = validate_dataset_id(kind, context="media kind")
+ if kind_id not in MEDIA_KINDS:
+ choices = ", ".join(sorted(MEDIA_KINDS))
+ raise LayoutError(f"media kind must be one of {{{choices}}}")
+ return f"{base}/{kind_id}"
+
+
+def artifact_directory(benchmark: str) -> str:
+ """Return the canonical artifact directory for a benchmark."""
+
+ return f"{ARTIFACTS_DIRECTORY}/{benchmark_directory(benchmark)}"
+
+
+def path_is_within(path: str, directory: str) -> bool:
+ """Return whether a canonical repository path is at or below a directory."""
+
+ path_parts = PurePosixPath(validate_repository_path(path)).parts
+ directory_parts = PurePosixPath(validate_repository_path(directory)).parts
+ return path_parts[: len(directory_parts)] == directory_parts
+
+
+def validate_annotation_path(value: object, benchmark: str, split: str) -> str:
+ """Require the exact canonical annotation path for a benchmark split."""
+
+ path = validate_repository_path(value, context="annotation_path")
+ expected = annotation_path(benchmark, split)
+ if path != expected:
+ raise LayoutError(f"annotation_path must be {expected!r}, got {path!r}")
+ return path
+
+
+def validate_media_path(
+ value: object,
+ benchmark: str,
+ *,
+ kind: str | None = None,
+ allow_directory: bool = False,
+ context: str = "media path",
+) -> str:
+ """Require a path below the benchmark's canonical media directory."""
+
+ path = validate_repository_path(value, context=context)
+ directory = media_directory(benchmark, kind)
+ if not path_is_within(path, directory):
+ raise LayoutError(f"{context} must be within {directory!r}, got {path!r}")
+ if not allow_directory and path == directory:
+ raise LayoutError(f"{context} must name an asset below {directory!r}")
+ return path
+
+
+def validate_artifact_path(
+ value: object,
+ benchmark: str,
+ *,
+ allow_directory: bool = False,
+ context: str = "artifact path",
+) -> str:
+ """Require a path below the benchmark's canonical artifact directory."""
+
+ path = validate_repository_path(value, context=context)
+ directory = artifact_directory(benchmark)
+ if not path_is_within(path, directory):
+ raise LayoutError(f"{context} must be within {directory!r}, got {path!r}")
+ if not allow_directory and path == directory:
+ raise LayoutError(f"{context} must name an asset below {directory!r}")
+ return path
+
+
+def validate_unique_paths(
+ paths: Iterable[str],
+ *,
+ context: str = "paths",
+ allow_exact_duplicates: bool = False,
+) -> tuple[str, ...]:
+ """Validate path spelling and reject duplicates or case collisions."""
+
+ normalized: list[str] = []
+ seen: dict[str, str] = {}
+ for index, value in enumerate(paths):
+ path = validate_repository_path(value, context=f"{context}[{index}]")
+ key = path.casefold()
+ previous = seen.get(key)
+ if previous is not None:
+ if previous != path:
+ raise LayoutError(
+ f"{context} contain a case-colliding path: {previous!r} and {path!r}"
+ )
+ if not allow_exact_duplicates:
+ raise LayoutError(f"{context} contain duplicate path {path!r}")
+ else:
+ seen[key] = path
+ normalized.append(path)
+ return tuple(normalized)
+
+
+def sha256_file(path: Union[str, Path]) -> str:
+ """Return the SHA-256 digest of one file."""
+
+ digest = hashlib.sha256()
+ with Path(path).open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def validate_sha256(value: object, *, context: str = "sha256") -> str:
+ """Validate and return a lowercase hexadecimal SHA-256 digest."""
+
+ if not isinstance(value, str) or not _SHA256_RE.fullmatch(value):
+ raise LayoutError(f"{context} must be 64 lowercase hexadecimal characters")
+ return value
+
+
+def validate_repository_assets(
+ paths: Iterable[str],
+ repository_root: Union[str, Path],
+ *,
+ checksums: Mapping[str, str] | None = None,
+ file_paths: Iterable[str] = (),
+ context: str = "assets",
+) -> dict[str, Path]:
+ """Require local assets and optionally verify their SHA-256 checksums.
+
+ Directories are accepted for declared media and artifact roots. Paths in
+ ``file_paths`` and every checksummed path must resolve to regular files.
+ Existing symlinks are resolved and may not escape ``repository_root``.
+ """
+
+ root = Path(repository_root).expanduser().resolve()
+ if not root.is_dir():
+ raise LayoutError(f"{context} repository root does not exist: {root}")
+
+ expected_checksums: dict[str, str] = {}
+ for raw_path, raw_digest in (checksums or {}).items():
+ path = validate_repository_path(raw_path, context=f"{context} checksum path")
+ expected_checksums[path] = validate_sha256(
+ raw_digest,
+ context=f"{context} checksum for {path}",
+ )
+
+ required_files = {
+ validate_repository_path(path, context=f"{context} file path") for path in file_paths
+ }
+ all_paths = list(paths)
+ all_paths.extend(expected_checksums)
+ all_paths.extend(required_files)
+ normalized = validate_unique_paths(
+ all_paths,
+ context=context,
+ allow_exact_duplicates=True,
+ )
+
+ resolved: dict[str, Path] = {}
+ for path in normalized:
+ candidate = root.joinpath(*PurePosixPath(path).parts)
+ try:
+ asset = candidate.resolve(strict=True)
+ except FileNotFoundError as error:
+ raise LayoutError(f"{context} path does not exist: {path}") from error
+ try:
+ asset.relative_to(root)
+ except ValueError as error:
+ raise LayoutError(f"{context} path resolves outside the repository: {path}") from error
+
+ if path in required_files or path in expected_checksums:
+ if not asset.is_file():
+ raise LayoutError(f"{context} path must be a file: {path}")
+ elif not asset.is_file() and not asset.is_dir():
+ raise LayoutError(f"{context} path is not a file or directory: {path}")
+
+ expected = expected_checksums.get(path)
+ if expected is not None:
+ actual = sha256_file(asset)
+ if actual != expected:
+ raise LayoutError(
+ f"{context} checksum mismatch for {path}: expected {expected}, got {actual}"
+ )
+ resolved[path] = asset
+ return resolved
diff --git a/code/orarl/evaluation/manifest.py b/code/orarl/evaluation/manifest.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc2f98face850b32da0bfc2aa46b691f1d074704
--- /dev/null
+++ b/code/orarl/evaluation/manifest.py
@@ -0,0 +1,558 @@
+"""Validation and loading for the canonical ``datasets.jsonl`` manifest."""
+
+from __future__ import annotations
+
+import json
+import re
+from collections.abc import Iterable, Mapping
+from pathlib import Path
+from typing import Any, TypedDict, Union
+from urllib.parse import urlsplit
+
+from .layout import (
+ MANIFEST_FILENAME,
+ LayoutError,
+ artifact_directory,
+ media_directory,
+ path_is_within,
+ validate_annotation_path,
+ validate_artifact_path,
+ validate_dataset_id,
+ validate_media_path,
+ validate_repository_assets,
+ validate_repository_path,
+ validate_sha256,
+ validate_unique_paths,
+)
+from .schema import (
+ EVALUATION_SCHEMA_VERSION,
+ EvaluationSchemaError,
+ declared_repository_paths,
+ evaluation_asset_paths,
+ load_evaluation_jsonl,
+)
+
+_ENVIRONMENT_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
+_PATH_ENV_SUFFIXES = (
+ "_FILE",
+ "_DIR",
+ "_ROOT",
+ "_PATH",
+ "_CKPT",
+ "_CFG",
+ "_BASE",
+ "_PREFIX",
+)
+
+
+class _RequiredDatasetManifestRecord(TypedDict):
+ schema_version: int
+ benchmark: str
+ split: str
+ task: str
+ annotation_path: str
+ media_paths: list[str]
+ artifact_paths: list[str]
+ expected_count: int
+ license: str
+ source_url: str
+ redistribution_authorized: bool
+ evaluation: dict[str, Any]
+
+
+class DatasetManifestRecord(_RequiredDatasetManifestRecord, total=False):
+ """One canonical manifest record for a benchmark/split pair."""
+
+ family: str
+ preprocessing: dict[str, Any]
+ legacy_environment: dict[str, Any]
+ checksums: dict[str, str]
+ metadata: dict[str, Any]
+
+
+class ManifestError(ValueError):
+ """Raised when ``datasets.jsonl`` or its referenced repository is invalid."""
+
+
+def _append_layout_error(errors: list[str], callback: Any) -> None:
+ try:
+ callback()
+ except LayoutError as error:
+ errors.append(str(error))
+
+
+def _valid_source_url(value: object) -> bool:
+ if not isinstance(value, str) or not value or value != value.strip():
+ return False
+ if any(character.isspace() for character in value):
+ return False
+ parsed = urlsplit(value)
+ return (
+ parsed.scheme in {"http", "https"}
+ and bool(parsed.netloc)
+ and parsed.username is None
+ and parsed.password is None
+ )
+
+
+def _string_paths(
+ value: Any,
+ field: str,
+ errors: list[str],
+) -> list[str]:
+ if not isinstance(value, list):
+ errors.append(f"{field} must be a list")
+ return []
+ paths: list[str] = []
+ for index, path in enumerate(value):
+ if not isinstance(path, str):
+ errors.append(f"{field}[{index}] must be a repository path string")
+ else:
+ paths.append(path)
+ return paths
+
+
+def _manifest_checksums(record: Mapping[str, Any], errors: list[str]) -> dict[str, str]:
+ raw_checksums = record.get("checksums")
+ if raw_checksums is None:
+ return {}
+ if not isinstance(raw_checksums, Mapping):
+ errors.append("checksums must be a JSON object when provided")
+ return {}
+
+ checksums: dict[str, str] = {}
+ for raw_path, raw_digest in raw_checksums.items():
+ if not isinstance(raw_path, str):
+ errors.append("checksum keys must be repository path strings")
+ continue
+ try:
+ path = validate_repository_path(raw_path, context="checksum path")
+ digest = validate_sha256(raw_digest, context=f"checksum for {path}")
+ except LayoutError as error:
+ errors.append(str(error))
+ continue
+ checksums[path] = digest
+ _append_layout_error(
+ errors,
+ lambda: validate_unique_paths(checksums, context="checksum paths"),
+ )
+ return checksums
+
+
+def dataset_manifest_record_errors(record: Any) -> list[str]:
+ """Collect schema and canonical-layout errors for one manifest record."""
+
+ if not isinstance(record, Mapping):
+ return ["record must be a JSON object"]
+
+ errors: list[str] = []
+ version = record.get("schema_version")
+ if isinstance(version, bool) or version != EVALUATION_SCHEMA_VERSION:
+ errors.append(f"schema_version must be {EVALUATION_SCHEMA_VERSION}")
+
+ for field in ("benchmark", "split", "task"):
+ value = record.get(field)
+ _append_layout_error(
+ errors,
+ lambda value=value, field=field: validate_dataset_id(value, context=field),
+ )
+
+ family = record.get("family")
+ if family is not None:
+ _append_layout_error(
+ errors,
+ lambda: validate_dataset_id(family, context="family"),
+ )
+
+ benchmark = record.get("benchmark")
+ split = record.get("split")
+ if isinstance(benchmark, str) and isinstance(split, str):
+ _append_layout_error(
+ errors,
+ lambda: validate_annotation_path(
+ record.get("annotation_path"),
+ benchmark,
+ split,
+ ),
+ )
+ elif "annotation_path" not in record:
+ errors.append("annotation_path is required")
+
+ media_paths = _string_paths(record.get("media_paths"), "media_paths", errors)
+ artifact_paths = _string_paths(record.get("artifact_paths"), "artifact_paths", errors)
+ if isinstance(benchmark, str):
+ for index, path in enumerate(media_paths):
+ _append_layout_error(
+ errors,
+ lambda path=path, index=index: validate_media_path(
+ path,
+ benchmark,
+ allow_directory=True,
+ context=f"media_paths[{index}]",
+ ),
+ )
+ for index, path in enumerate(artifact_paths):
+ _append_layout_error(
+ errors,
+ lambda path=path, index=index: validate_artifact_path(
+ path,
+ benchmark,
+ allow_directory=True,
+ context=f"artifact_paths[{index}]",
+ ),
+ )
+ _append_layout_error(
+ errors,
+ lambda: validate_unique_paths(media_paths, context="media_paths"),
+ )
+ _append_layout_error(
+ errors,
+ lambda: validate_unique_paths(artifact_paths, context="artifact_paths"),
+ )
+
+ expected_count = record.get("expected_count")
+ if (
+ isinstance(expected_count, bool)
+ or not isinstance(expected_count, int)
+ or expected_count < 0
+ ):
+ errors.append("expected_count must be a nonnegative integer")
+
+ license_name = record.get("license")
+ if not isinstance(license_name, str) or not license_name.strip():
+ errors.append("license must be a nonempty string")
+ if not _valid_source_url(record.get("source_url")):
+ errors.append("source_url must be an HTTP(S) URL without embedded credentials")
+ if not isinstance(record.get("redistribution_authorized"), bool):
+ errors.append("redistribution_authorized must be an explicit boolean")
+
+ evaluation = record.get("evaluation")
+ if not isinstance(evaluation, Mapping) or not evaluation:
+ errors.append("evaluation must be a nonempty JSON object")
+
+ for field in ("preprocessing", "metadata"):
+ value = record.get(field)
+ if value is not None and not isinstance(value, Mapping):
+ errors.append(f"{field} must be a JSON object when provided")
+
+ legacy = record.get("legacy_environment")
+ legacy_paths: list[str] = []
+ if legacy is not None:
+ if not isinstance(legacy, Mapping):
+ errors.append("legacy_environment must be a JSON object when provided")
+ else:
+ for name, value in legacy.items():
+ if not isinstance(name, str) or not _ENVIRONMENT_NAME_RE.fullmatch(name):
+ errors.append(
+ "legacy_environment names must use uppercase letters, digits, "
+ "and underscores"
+ )
+ if isinstance(value, (Mapping, tuple, set)) or value is None:
+ errors.append(
+ f"legacy_environment[{name!r}] must be a JSON scalar or list"
+ )
+ continue
+ if isinstance(value, list) and not all(
+ isinstance(item, (str, int, float, bool)) for item in value
+ ):
+ errors.append(
+ f"legacy_environment[{name!r}] lists must contain only JSON scalars"
+ )
+ continue
+ if isinstance(name, str) and name.endswith(_PATH_ENV_SUFFIXES):
+ if not isinstance(value, str):
+ errors.append(
+ f"legacy_environment[{name!r}] must be a path string"
+ )
+ continue
+ legacy_paths.append(value)
+ _append_layout_error(
+ errors,
+ lambda value=value, name=name: validate_repository_path(
+ value,
+ context=f"legacy_environment[{name!r}]",
+ ),
+ )
+
+ profile_paths: list[str] = []
+ for field in ("preprocessing", "evaluation", "metadata"):
+ value = record.get(field)
+ if isinstance(value, Mapping):
+ try:
+ declared = declared_repository_paths(value, context=field)
+ except LayoutError as error:
+ errors.append(str(error))
+ continue
+ for context, path in declared:
+ profile_paths.append(path)
+ _append_layout_error(
+ errors,
+ lambda path=path, context=context: validate_repository_path(
+ path,
+ context=context,
+ ),
+ )
+
+ checksums = _manifest_checksums(record, errors)
+ declared_paths = []
+ annotation = record.get("annotation_path")
+ if isinstance(annotation, str):
+ declared_paths.append(annotation)
+ declared_paths.extend(media_paths)
+ declared_paths.extend(artifact_paths)
+ declared_paths.extend(legacy_paths)
+ declared_paths.extend(profile_paths)
+ declared_paths.extend(checksums)
+ _append_layout_error(
+ errors,
+ lambda: validate_unique_paths(
+ declared_paths,
+ context="manifest paths",
+ allow_exact_duplicates=True,
+ ),
+ )
+
+ try:
+ json.dumps(record, ensure_ascii=False, allow_nan=False)
+ except (TypeError, ValueError) as error:
+ errors.append(f"record must contain only finite JSON values: {error}")
+ return errors
+
+
+def validate_dataset_manifest_record(
+ record: Any,
+ *,
+ context: str = "manifest record",
+) -> Mapping[str, Any]:
+ """Validate one ``datasets.jsonl`` record and return it unchanged."""
+
+ errors = dataset_manifest_record_errors(record)
+ if errors:
+ raise ManifestError(f"{context}: " + "; ".join(errors))
+ return record
+
+
+def _record_paths(record: Mapping[str, Any]) -> tuple[list[str], list[str]]:
+ paths = [str(record["annotation_path"])]
+ paths.extend(str(path) for path in record["media_paths"])
+ paths.extend(str(path) for path in record["artifact_paths"])
+ legacy = record.get("legacy_environment")
+ if isinstance(legacy, Mapping):
+ paths.extend(
+ str(value)
+ for name, value in legacy.items()
+ if isinstance(name, str)
+ and name.endswith(_PATH_ENV_SUFFIXES)
+ and isinstance(value, str)
+ )
+ for field in ("preprocessing", "evaluation", "metadata"):
+ payload = record.get(field)
+ if isinstance(payload, Mapping):
+ paths.extend(path for _context, path in declared_repository_paths(payload))
+
+ files = [str(record["annotation_path"])]
+ checksums = record.get("checksums")
+ if isinstance(checksums, Mapping):
+ files.extend(str(path) for path in checksums)
+ return paths, files
+
+
+def validate_dataset_manifest(
+ records: Iterable[Any],
+ *,
+ repository_root: Union[str, Path, None] = None,
+ checksums: Mapping[str, str] | None = None,
+ context: str = MANIFEST_FILENAME,
+) -> list[Mapping[str, Any]]:
+ """Validate manifest records, dataset uniqueness, paths, and optional assets."""
+
+ validated: list[Mapping[str, Any]] = []
+ datasets: dict[tuple[str, str], int] = {}
+ path_spellings: dict[str, str] = {}
+ asset_paths: list[str] = []
+ file_paths: list[str] = []
+ merged_checksums: dict[str, str] = {}
+
+ for index, record in enumerate(records, start=1):
+ row_context = f"{context}:{index}"
+ validated_record = validate_dataset_manifest_record(record, context=row_context)
+ key = (
+ str(validated_record["benchmark"]),
+ str(validated_record["split"]),
+ )
+ previous = datasets.get(key)
+ if previous is not None:
+ raise ManifestError(
+ f"{row_context}: duplicate dataset {key[0]}/{key[1]} "
+ f"(first seen at row {previous})"
+ )
+ datasets[key] = index
+
+ paths, files = _record_paths(validated_record)
+ for path in paths:
+ folded = path.casefold()
+ previous_path = path_spellings.get(folded)
+ if previous_path is not None and previous_path != path:
+ raise ManifestError(
+ f"{row_context}: manifest path case collision: "
+ f"{previous_path!r} and {path!r}"
+ )
+ path_spellings[folded] = path
+ asset_paths.extend(paths)
+ file_paths.extend(files)
+
+ record_checksums = validated_record.get("checksums")
+ if isinstance(record_checksums, Mapping):
+ for path, digest in record_checksums.items():
+ previous_digest = merged_checksums.get(str(path))
+ if previous_digest is not None and previous_digest != digest:
+ raise ManifestError(f"{row_context}: conflicting checksums for {path}")
+ merged_checksums[str(path)] = str(digest)
+ validated.append(validated_record)
+
+ for raw_path, raw_digest in (checksums or {}).items():
+ try:
+ path = validate_repository_path(raw_path, context="checksum path")
+ digest = validate_sha256(raw_digest, context=f"checksum for {path}")
+ except LayoutError as error:
+ raise ManifestError(f"{context}: {error}") from error
+ previous_digest = merged_checksums.get(path)
+ if previous_digest is not None and previous_digest != digest:
+ raise ManifestError(f"{context}: conflicting checksums for {path}")
+ merged_checksums[path] = digest
+
+ if checksums is not None and repository_root is None:
+ raise ManifestError(f"{context}: repository_root is required for checksums")
+ if repository_root is not None:
+ try:
+ validate_repository_assets(
+ asset_paths,
+ repository_root,
+ checksums=merged_checksums,
+ file_paths=file_paths,
+ context=f"{context} assets",
+ )
+ except LayoutError as error:
+ raise ManifestError(str(error)) from error
+ return validated
+
+
+def _manifest_path(path: Union[str, Path]) -> Path:
+ candidate = Path(path)
+ if candidate.is_dir():
+ candidate = candidate / MANIFEST_FILENAME
+ if candidate.name != MANIFEST_FILENAME:
+ raise ManifestError(f"evaluation manifest must be named {MANIFEST_FILENAME}")
+ return candidate
+
+
+def load_dataset_manifest(
+ path: Union[str, Path],
+ *,
+ repository_root: Union[str, Path, None] = None,
+ checksums: Mapping[str, str] | None = None,
+) -> list[Mapping[str, Any]]:
+ """Load and validate ``datasets.jsonl`` from a file or repository root."""
+
+ input_path = _manifest_path(path)
+ records: list[Any] = []
+ with input_path.open("r", encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, start=1):
+ if not line.strip():
+ continue
+ try:
+ record = json.loads(line)
+ except json.JSONDecodeError as error:
+ raise ManifestError(
+ f"{input_path}:{line_number}: invalid JSON: {error}"
+ ) from error
+ records.append(record)
+ return validate_dataset_manifest(
+ records,
+ repository_root=repository_root,
+ checksums=checksums,
+ context=str(input_path),
+ )
+
+
+def _path_is_declared(path: str, declarations: Iterable[str]) -> bool:
+ return any(path_is_within(path, declaration) for declaration in declarations)
+
+
+def validate_evaluation_repository(
+ repository_root: Union[str, Path],
+ *,
+ checksums: Mapping[str, str] | None = None,
+ require_redistribution_authorized: bool = True,
+) -> dict[tuple[str, str], list[Mapping[str, Any]]]:
+ """Validate a staged repository, its manifests, row counts, and all assets."""
+
+ root = Path(repository_root)
+ manifest = load_dataset_manifest(
+ root,
+ repository_root=root,
+ checksums=checksums,
+ )
+ datasets: dict[tuple[str, str], list[Mapping[str, Any]]] = {}
+ path_spellings: dict[str, str] = {}
+
+ for index, dataset in enumerate(manifest, start=1):
+ benchmark = str(dataset["benchmark"])
+ split = str(dataset["split"])
+ task = str(dataset["task"])
+ if require_redistribution_authorized and not dataset["redistribution_authorized"]:
+ raise ManifestError(
+ f"{MANIFEST_FILENAME}:{index}: redistribution is not authorized for "
+ f"{benchmark}/{split}"
+ )
+
+ annotation = root.joinpath(str(dataset["annotation_path"]))
+ try:
+ rows = load_evaluation_jsonl(
+ annotation,
+ benchmark=benchmark,
+ split=split,
+ eval_task=task,
+ repository_root=root,
+ )
+ except EvaluationSchemaError as error:
+ raise ManifestError(str(error)) from error
+
+ expected_count = int(dataset["expected_count"])
+ if len(rows) != expected_count:
+ raise ManifestError(
+ f"{benchmark}/{split}: expected {expected_count} rows, found {len(rows)}"
+ )
+
+ media_paths = [str(path) for path in dataset["media_paths"]]
+ artifact_paths = [str(path) for path in dataset["artifact_paths"]]
+ media_root = f"{media_directory(benchmark)}/"
+ artifact_root = f"{artifact_directory(benchmark)}/"
+ for row_number, row in enumerate(rows, start=1):
+ for path in evaluation_asset_paths(row):
+ if path.startswith(media_root):
+ declarations = media_paths
+ elif path.startswith(artifact_root):
+ declarations = artifact_paths
+ else:
+ declarations = []
+ if declarations and not _path_is_declared(path, declarations):
+ raise ManifestError(
+ f"{benchmark}/{split}:{row_number}: asset {path!r} is not covered "
+ "by the dataset manifest"
+ )
+ if not declarations and path.startswith(("media/", "artifacts/")):
+ raise ManifestError(
+ f"{benchmark}/{split}:{row_number}: asset {path!r} has no declared root"
+ )
+
+ folded = path.casefold()
+ previous = path_spellings.get(folded)
+ if previous is not None and previous != path:
+ raise ManifestError(
+ f"{benchmark}/{split}:{row_number}: asset path case collision: "
+ f"{previous!r} and {path!r}"
+ )
+ path_spellings[folded] = path
+ datasets[(benchmark, split)] = rows
+ return datasets
diff --git a/code/orarl/evaluation/schema.py b/code/orarl/evaluation/schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d13268e4a48c0bd6ad9a3a13d24b12397558333
--- /dev/null
+++ b/code/orarl/evaluation/schema.py
@@ -0,0 +1,460 @@
+"""Canonical JSONL contract for OraRL evaluation samples."""
+
+from __future__ import annotations
+
+import json
+import re
+from collections.abc import Iterable, Mapping, Sequence
+from pathlib import Path
+from typing import Any, TypedDict, Union
+
+from orarl.data.schema import (
+ CANONICAL_FIELDS,
+ MEDIA_PATH_KEYS,
+)
+from orarl.data.schema import validation_errors as core_validation_errors
+
+from .layout import (
+ LayoutError,
+ artifact_directory,
+ is_dataset_id,
+ validate_artifact_path,
+ validate_dataset_id,
+ validate_media_path,
+ validate_repository_assets,
+ validate_repository_path,
+)
+
+EVALUATION_SCHEMA_VERSION = 1
+EVALUATION_CORE_FIELDS = CANONICAL_FIELDS
+EVALUATION_REQUIRED_FIELDS = (
+ "schema_version",
+ "eval_task",
+ "sample_id",
+ "benchmark",
+ "split",
+ *EVALUATION_CORE_FIELDS,
+)
+EVALUATION_FIELDS = (
+ *EVALUATION_REQUIRED_FIELDS,
+ "family",
+ "choices",
+ "subtitles",
+ "preprocessed",
+ "task_payload",
+ "metadata",
+ "evaluation",
+)
+
+_PATH_KEY_RE = re.compile(
+ r"(?:^|_)(?:artifact|artifacts|dir|directory|file|files|image|images|"
+ r"media|path|paths|root|subtitle|subtitles|tensor|tensors|video|videos)$"
+)
+
+
+class _RequiredEvaluationRow(TypedDict):
+ schema_version: int
+ eval_task: str
+ sample_id: str
+ benchmark: str
+ split: str
+ problem: str
+ answer: Any
+ images: list[Any]
+ videos: list[Any]
+ problem_type: str
+ source: str
+
+
+class EvaluationRow(_RequiredEvaluationRow, total=False):
+ """Typed canonical evaluation row.
+
+ ``task_payload`` retains benchmark-specific coordinates, masks, temporal
+ labels, and grouping information without flattening incompatible schemas.
+ """
+
+ family: str
+ choices: list[str]
+ subtitles: list[Any]
+ preprocessed: dict[str, Any]
+ task_payload: dict[str, Any]
+ metadata: dict[str, Any]
+ evaluation: dict[str, Any]
+
+
+class EvaluationSchemaError(ValueError):
+ """Raised when canonical evaluation rows are invalid or ambiguous."""
+
+
+def _append_layout_error(errors: list[str], callback: Any) -> None:
+ try:
+ callback()
+ except LayoutError as error:
+ errors.append(str(error))
+
+
+def _media_entry_paths(value: Any) -> list[str]:
+ if isinstance(value, str):
+ return [value]
+ if not isinstance(value, Mapping):
+ return []
+ return [
+ candidate
+ for key in MEDIA_PATH_KEYS
+ if isinstance((candidate := value.get(key)), str)
+ ]
+
+
+def _is_path_key(key: object) -> bool:
+ return isinstance(key, str) and bool(_PATH_KEY_RE.search(key.casefold()))
+
+
+def _path_values(value: Any, context: str) -> Iterable[tuple[str, str]]:
+ if isinstance(value, str):
+ yield context, value
+ return
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ for index, item in enumerate(value):
+ yield from _path_values(item, f"{context}[{index}]")
+ return
+ if isinstance(value, Mapping):
+ matched = False
+ for key, item in value.items():
+ if _is_path_key(key):
+ matched = True
+ yield from _path_values(item, f"{context}.{key}")
+ if matched:
+ return
+ raise LayoutError(f"{context} must contain a path string or list of path strings")
+
+
+def _declared_paths(value: Any, context: str) -> Iterable[tuple[str, str]]:
+ if isinstance(value, Mapping):
+ for key, item in value.items():
+ child_context = f"{context}.{key}"
+ if _is_path_key(key):
+ yield from _path_values(item, child_context)
+ elif isinstance(item, (Mapping, list, tuple)):
+ yield from _declared_paths(item, child_context)
+ elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ for index, item in enumerate(value):
+ yield from _declared_paths(item, f"{context}[{index}]")
+
+
+def declared_repository_paths(
+ value: Any,
+ *,
+ context: str = "payload",
+) -> tuple[tuple[str, str], ...]:
+ """Return paths held by explicitly path-named keys in a JSON value."""
+
+ return tuple(_declared_paths(value, context))
+
+
+def _evaluation_paths(record: Mapping[str, Any]) -> Iterable[tuple[str, str, str | None]]:
+ benchmark = record.get("benchmark")
+ benchmark_id = benchmark if isinstance(benchmark, str) else ""
+
+ for field in ("images", "videos"):
+ values = record.get(field)
+ if not isinstance(values, list):
+ continue
+ for index, entry in enumerate(values):
+ for path_index, path in enumerate(_media_entry_paths(entry)):
+ suffix = "" if path_index == 0 else f".path[{path_index}]"
+ yield f"{field}[{index}]{suffix}", path, field
+
+ subtitles = record.get("subtitles")
+ if isinstance(subtitles, list):
+ for index, entry in enumerate(subtitles):
+ for path_index, path in enumerate(_media_entry_paths(entry)):
+ suffix = "" if path_index == 0 else f".path[{path_index}]"
+ yield f"subtitles[{index}]{suffix}", path, "subtitles"
+
+ preprocessed = record.get("preprocessed")
+ if isinstance(preprocessed, Mapping):
+ for context, path in _declared_paths(preprocessed, "preprocessed"):
+ yield context, path, "artifacts"
+
+ for field in ("task_payload", "metadata", "evaluation"):
+ payload = record.get(field)
+ if isinstance(payload, Mapping):
+ for context, path in _declared_paths(payload, field):
+ kind: str | None = None
+ if benchmark_id and path.startswith(
+ f"{artifact_directory(benchmark_id)}/"
+ ):
+ kind = "artifacts"
+ yield context, path, kind
+
+
+def evaluation_asset_paths(record: Mapping[str, Any]) -> tuple[str, ...]:
+ """Return every first-class or explicitly named asset path in a row."""
+
+ paths: list[str] = []
+ seen: set[str] = set()
+ for _context, path, _kind in _evaluation_paths(record):
+ if path not in seen:
+ seen.add(path)
+ paths.append(path)
+ return tuple(paths)
+
+
+def evaluation_row_errors(record: Any) -> list[str]:
+ """Collect all deterministic schema and layout errors for one eval row."""
+
+ if not isinstance(record, Mapping):
+ return ["record must be a JSON object"]
+
+ errors = list(core_validation_errors(record))
+ version = record.get("schema_version")
+ if isinstance(version, bool) or version != EVALUATION_SCHEMA_VERSION:
+ errors.append(f"schema_version must be {EVALUATION_SCHEMA_VERSION}")
+
+ for field in ("eval_task", "benchmark", "split"):
+ value = record.get(field)
+ _append_layout_error(
+ errors,
+ lambda value=value, field=field: validate_dataset_id(value, context=field),
+ )
+
+ sample_id = record.get("sample_id")
+ if not isinstance(sample_id, str) or not sample_id.strip():
+ errors.append("sample_id must be a nonempty string")
+ elif sample_id != sample_id.strip():
+ errors.append("sample_id must not contain surrounding whitespace")
+
+ family = record.get("family")
+ if family is not None:
+ _append_layout_error(
+ errors,
+ lambda: validate_dataset_id(family, context="family"),
+ )
+
+ choices = record.get("choices")
+ if choices is not None:
+ if not isinstance(choices, list):
+ errors.append("choices must be a list when provided")
+ else:
+ for index, choice in enumerate(choices):
+ if not isinstance(choice, str) or not choice.strip():
+ errors.append(f"choices[{index}] must be a nonempty string")
+
+ subtitles = record.get("subtitles")
+ if subtitles is not None:
+ if not isinstance(subtitles, list):
+ errors.append("subtitles must be a list when provided")
+ else:
+ for index, entry in enumerate(subtitles):
+ if not _media_entry_paths(entry):
+ errors.append(
+ f"subtitles[{index}] must contain a nonempty repository path"
+ )
+
+ for field in ("preprocessed", "task_payload", "metadata", "evaluation"):
+ value = record.get(field)
+ if value is not None and not isinstance(value, Mapping):
+ errors.append(f"{field} must be a JSON object when provided")
+
+ try:
+ declared_paths = list(_evaluation_paths(record))
+ except LayoutError as error:
+ errors.append(str(error))
+ declared_paths = []
+
+ benchmark = record.get("benchmark")
+ if is_dataset_id(benchmark):
+ for context, path, kind in declared_paths:
+ if kind in {"images", "videos", "subtitles"}:
+ _append_layout_error(
+ errors,
+ lambda path=path, context=context, kind=kind: validate_media_path(
+ path,
+ benchmark,
+ kind=kind,
+ context=context,
+ ),
+ )
+ elif kind == "artifacts":
+ _append_layout_error(
+ errors,
+ lambda path=path, context=context: validate_artifact_path(
+ path,
+ benchmark,
+ context=context,
+ ),
+ )
+ else:
+ _append_layout_error(
+ errors,
+ lambda path=path, context=context: validate_repository_path(
+ path,
+ context=context,
+ ),
+ )
+
+ try:
+ json.dumps(record, ensure_ascii=False, allow_nan=False)
+ except (TypeError, ValueError) as error:
+ errors.append(f"record must contain only finite JSON values: {error}")
+ return errors
+
+
+def validate_evaluation_row(
+ record: Any,
+ *,
+ context: str = "record",
+ repository_root: Union[str, Path, None] = None,
+ checksums: Mapping[str, str] | None = None,
+) -> Mapping[str, Any]:
+ """Validate one eval row and optionally verify all referenced assets."""
+
+ errors = evaluation_row_errors(record)
+ if errors:
+ raise EvaluationSchemaError(f"{context}: " + "; ".join(errors))
+
+ if checksums is not None and repository_root is None:
+ raise EvaluationSchemaError(f"{context}: repository_root is required for checksums")
+ if repository_root is not None:
+ try:
+ assets = evaluation_asset_paths(record)
+ validate_repository_assets(
+ assets,
+ repository_root,
+ checksums=checksums,
+ file_paths=assets,
+ context=f"{context} assets",
+ )
+ except LayoutError as error:
+ raise EvaluationSchemaError(str(error)) from error
+ return record
+
+
+def validate_evaluation_rows(
+ records: Iterable[Any],
+ *,
+ benchmark: str | None = None,
+ split: str | None = None,
+ eval_task: str | None = None,
+ repository_root: Union[str, Path, None] = None,
+ checksums: Mapping[str, str] | None = None,
+ context: str = "evaluation rows",
+) -> list[Mapping[str, Any]]:
+ """Validate eval rows, dataset membership, IDs, path spelling, and assets."""
+
+ expected = {
+ "benchmark": benchmark,
+ "split": split,
+ "eval_task": eval_task,
+ }
+ for field, value in expected.items():
+ if value is not None:
+ try:
+ validate_dataset_id(value, context=field)
+ except LayoutError as error:
+ raise EvaluationSchemaError(f"{context}: {error}") from error
+
+ validated: list[Mapping[str, Any]] = []
+ row_keys: dict[tuple[str, str, str], int] = {}
+ path_spellings: dict[str, str] = {}
+ asset_paths: list[str] = []
+ dataset_identity: tuple[str, str, str] | None = None
+
+ for index, record in enumerate(records, start=1):
+ row_context = f"{context}:{index}"
+ validated_record = validate_evaluation_row(record, context=row_context)
+ for field, value in expected.items():
+ if value is not None and validated_record.get(field) != value:
+ raise EvaluationSchemaError(
+ f"{row_context}: {field} must be {value!r}, "
+ f"got {validated_record.get(field)!r}"
+ )
+
+ key = (
+ str(validated_record["benchmark"]),
+ str(validated_record["split"]),
+ str(validated_record["sample_id"]),
+ )
+ identity = (
+ str(validated_record["benchmark"]),
+ str(validated_record["split"]),
+ str(validated_record["eval_task"]),
+ )
+ if dataset_identity is None:
+ dataset_identity = identity
+ elif identity != dataset_identity:
+ raise EvaluationSchemaError(
+ f"{row_context}: rows must belong to one benchmark/split/task; "
+ f"expected {dataset_identity[0]}/{dataset_identity[1]}/"
+ f"{dataset_identity[2]}, got {identity[0]}/{identity[1]}/{identity[2]}"
+ )
+ previous = row_keys.get(key)
+ if previous is not None:
+ raise EvaluationSchemaError(
+ f"{row_context}: duplicate sample_id {key[2]!r} for "
+ f"{key[0]}/{key[1]} (first seen at row {previous})"
+ )
+ row_keys[key] = index
+
+ for path in evaluation_asset_paths(validated_record):
+ folded = path.casefold()
+ previous_path = path_spellings.get(folded)
+ if previous_path is not None and previous_path != path:
+ raise EvaluationSchemaError(
+ f"{row_context}: asset path case collision: "
+ f"{previous_path!r} and {path!r}"
+ )
+ path_spellings[folded] = path
+ asset_paths.append(path)
+ validated.append(validated_record)
+
+ if checksums is not None and repository_root is None:
+ raise EvaluationSchemaError(f"{context}: repository_root is required for checksums")
+ if repository_root is not None:
+ try:
+ validate_repository_assets(
+ asset_paths,
+ repository_root,
+ checksums=checksums,
+ file_paths=asset_paths,
+ context=f"{context} assets",
+ )
+ except LayoutError as error:
+ raise EvaluationSchemaError(str(error)) from error
+ return validated
+
+
+def load_evaluation_jsonl(
+ path: Union[str, Path],
+ *,
+ benchmark: str | None = None,
+ split: str | None = None,
+ eval_task: str | None = None,
+ repository_root: Union[str, Path, None] = None,
+ checksums: Mapping[str, str] | None = None,
+) -> list[Mapping[str, Any]]:
+ """Load and validate canonical evaluation rows from a JSONL file."""
+
+ input_path = Path(path)
+ records: list[Any] = []
+ with input_path.open("r", encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, start=1):
+ if not line.strip():
+ continue
+ try:
+ record = json.loads(line)
+ except json.JSONDecodeError as error:
+ raise EvaluationSchemaError(
+ f"{input_path}:{line_number}: invalid JSON: {error}"
+ ) from error
+ records.append(record)
+
+ return validate_evaluation_rows(
+ records,
+ benchmark=benchmark,
+ split=split,
+ eval_task=eval_task,
+ repository_root=repository_root,
+ checksums=checksums,
+ context=str(input_path),
+ )
diff --git a/code/orarl/evaluation/sources.py b/code/orarl/evaluation/sources.py
new file mode 100644
index 0000000000000000000000000000000000000000..b73e22498932178f0da01516948223e4cd936034
--- /dev/null
+++ b/code/orarl/evaluation/sources.py
@@ -0,0 +1,404 @@
+"""Typed local source manifests for deterministic evaluation-data builds."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from collections.abc import Mapping
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, TypedDict, Union
+from urllib.parse import urlsplit
+
+from .layout import LayoutError, validate_dataset_id
+
+PathLike = Union[str, os.PathLike[str]]
+
+GENERIC_ADAPTERS = frozenset(
+ {
+ "generic",
+ "json",
+ "video_qa",
+ "one_thinker",
+ "onethinker",
+ "spatial_grounding",
+ }
+)
+SOURCE_ADAPTERS = frozenset(
+ {*GENERIC_ADAPTERS, "mmsi", "mindcube", "revsi", "timelens"}
+)
+_MEDIA_ROOT_KEYS = frozenset({"images", "videos", "subtitles", "default"})
+_URI_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
+_ENVIRONMENT_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
+
+
+class _RequiredEvaluationSourceRecord(TypedDict):
+ benchmark: str
+ eval_task: str
+ split: str
+ family: str
+ adapter: str
+ annotation_input: str
+ expected_count: int | None
+ license: str
+ source_url: str
+ redistribution_authorized: bool
+ evaluation: dict[str, Any]
+
+
+class EvaluationSourceRecord(_RequiredEvaluationSourceRecord, total=False):
+ """One JSONL source record for a benchmark/split conversion."""
+
+ annotation: str
+ annotation_path: str
+ input: str
+ media_root: str
+ media_roots: dict[str, str]
+ image_root: str
+ video_root: str
+ subtitle_root: str
+ artifact_root: str
+ preprocessed_root: str
+ preprocessed_roots: dict[str, str]
+ preprocessing: dict[str, Any]
+ legacy_environment: dict[str, Any]
+ metadata: dict[str, Any]
+
+
+class SourceManifestError(ValueError):
+ """Raised when a local evaluation source manifest is invalid."""
+
+
+@dataclass(frozen=True)
+class EvaluationSource:
+ """Resolved local source information that is never serialized to outputs."""
+
+ benchmark: str
+ eval_task: str
+ split: str
+ family: str
+ adapter: str
+ annotation_input: Path
+ media_roots: Mapping[str, Path]
+ preprocessed_roots: Mapping[str, Path]
+ expected_count: int | None
+ license: str
+ source_url: str
+ redistribution_authorized: bool
+ evaluation: Mapping[str, Any]
+ preprocessing: Mapping[str, Any]
+ legacy_environment: Mapping[str, Any]
+ metadata: Mapping[str, Any]
+ manifest_index: int
+
+ @property
+ def annotation(self) -> Path:
+ """Return the resolved annotation input path."""
+
+ return self.annotation_input
+
+ def media_root(self, kind: str) -> Path:
+ """Return the most specific source root for a media kind."""
+
+ return self.media_roots.get(kind, self.media_roots.get("default", self.annotation.parent))
+
+ def preprocessed_root(self, key: str | None = None) -> Path:
+ """Return a source artifact root selected by key, then by default."""
+
+ if key:
+ folded = key.casefold()
+ for name, root in self.preprocessed_roots.items():
+ if name != "default" and name.casefold() in folded:
+ return root
+ if "default" in self.preprocessed_roots:
+ return self.preprocessed_roots["default"]
+ if self.preprocessed_roots:
+ return self.preprocessed_roots[sorted(self.preprocessed_roots)[0]]
+ return self.annotation.parent
+
+
+def _local_path(base: Path, value: object, *, context: str) -> Path:
+ if not isinstance(value, str) or not value.strip() or value != value.strip():
+ raise SourceManifestError(f"{context} must be a nonempty local path string")
+ if _URI_SCHEME_RE.match(value):
+ raise SourceManifestError(f"{context} must be local, not a URL")
+ candidate = Path(os.path.expanduser(value))
+ if not candidate.is_absolute():
+ candidate = base / candidate
+ return Path(os.path.abspath(os.path.normpath(str(candidate))))
+
+
+def _required_text(record: Mapping[str, Any], field: str, *, context: str) -> str:
+ value = record.get(field)
+ if not isinstance(value, str) or not value.strip() or value != value.strip():
+ raise SourceManifestError(f"{context}.{field} must be a nonempty string")
+ return value
+
+
+def _mapping(record: Mapping[str, Any], field: str, *, context: str) -> dict[str, Any]:
+ value = record.get(field)
+ if value is None:
+ return {}
+ if not isinstance(value, Mapping):
+ raise SourceManifestError(f"{context}.{field} must be a JSON object")
+ return {str(key): item for key, item in value.items()}
+
+
+def _source_url(record: Mapping[str, Any], *, context: str) -> str:
+ value = _required_text(record, "source_url", context=context)
+ parsed = urlsplit(value)
+ if (
+ parsed.scheme not in {"http", "https"}
+ or not parsed.netloc
+ or parsed.username is not None
+ or parsed.password is not None
+ or any(character.isspace() for character in value)
+ ):
+ raise SourceManifestError(
+ f"{context}.source_url must be an HTTP(S) URL without embedded credentials"
+ )
+ return value
+
+
+def _annotation_value(record: Mapping[str, Any], *, context: str) -> object:
+ supplied = [
+ (field, record[field])
+ for field in ("annotation_input", "annotation", "annotation_path", "input")
+ if record.get(field) is not None
+ ]
+ if not supplied:
+ raise SourceManifestError(f"{context}.annotation_input is required")
+ spellings = {str(value) for _field, value in supplied}
+ if len(spellings) != 1:
+ fields = ", ".join(field for field, _value in supplied)
+ raise SourceManifestError(f"{context} has conflicting annotation fields: {fields}")
+ return supplied[0][1]
+
+
+def _root_mapping(
+ record: Mapping[str, Any],
+ base: Path,
+ *,
+ context: str,
+) -> tuple[dict[str, Path], dict[str, Path]]:
+ media: dict[str, Path] = {}
+ media_root = record.get("media_root")
+ if media_root is not None:
+ media["default"] = _local_path(base, media_root, context=f"{context}.media_root")
+
+ raw_media = record.get("media_roots")
+ if raw_media is not None:
+ if not isinstance(raw_media, Mapping):
+ raise SourceManifestError(f"{context}.media_roots must be a JSON object")
+ for raw_kind, raw_path in raw_media.items():
+ kind = str(raw_kind).casefold().removesuffix("_root")
+ if kind in {"image", "video", "subtitle"}:
+ kind += "s"
+ if kind not in _MEDIA_ROOT_KEYS:
+ choices = ", ".join(sorted(_MEDIA_ROOT_KEYS))
+ raise SourceManifestError(
+ f"{context}.media_roots keys must be one of {{{choices}}}"
+ )
+ media[kind] = _local_path(
+ base,
+ raw_path,
+ context=f"{context}.media_roots[{raw_kind!r}]",
+ )
+
+ for field, kind in (
+ ("image_root", "images"),
+ ("video_root", "videos"),
+ ("subtitle_root", "subtitles"),
+ ):
+ if record.get(field) is not None:
+ media[kind] = _local_path(base, record[field], context=f"{context}.{field}")
+
+ preprocessed: dict[str, Path] = {}
+ raw_preprocessed_root = record.get(
+ "preprocessed_root",
+ record.get("artifact_root"),
+ )
+ if raw_preprocessed_root is not None:
+ preprocessed["default"] = _local_path(
+ base,
+ raw_preprocessed_root,
+ context=f"{context}.preprocessed_root",
+ )
+ raw_preprocessed = record.get("preprocessed_roots")
+ if raw_preprocessed is not None:
+ if not isinstance(raw_preprocessed, Mapping):
+ raise SourceManifestError(f"{context}.preprocessed_roots must be a JSON object")
+ for raw_name, raw_path in raw_preprocessed.items():
+ name = str(raw_name).strip().casefold()
+ if not name:
+ raise SourceManifestError(f"{context}.preprocessed_roots keys must be nonempty")
+ preprocessed[name] = _local_path(
+ base,
+ raw_path,
+ context=f"{context}.preprocessed_roots[{raw_name!r}]",
+ )
+ return media, preprocessed
+
+
+def _source_from_record(
+ record: Mapping[str, Any],
+ manifest_path: Path,
+ index: int,
+) -> EvaluationSource:
+ context = f"{manifest_path}:{index}"
+ try:
+ benchmark = validate_dataset_id(record.get("benchmark"), context="benchmark")
+ eval_task = validate_dataset_id(record.get("eval_task"), context="eval_task")
+ split = validate_dataset_id(record.get("split"), context="split")
+ family = validate_dataset_id(record.get("family"), context="family")
+ except LayoutError as error:
+ raise SourceManifestError(f"{context}: {error}") from error
+
+ adapter = _required_text(record, "adapter", context=context).casefold().replace("-", "_")
+ if adapter not in SOURCE_ADAPTERS:
+ choices = ", ".join(sorted(SOURCE_ADAPTERS))
+ raise SourceManifestError(f"{context}.adapter must be one of {{{choices}}}")
+
+ annotation = _local_path(
+ manifest_path.parent,
+ _annotation_value(record, context=context),
+ context=f"{context}.annotation_input",
+ )
+ if not annotation.is_file():
+ raise SourceManifestError(f"{context}.annotation_input does not exist: {annotation}")
+ if annotation.suffix.casefold() in {".yaml", ".yml"}:
+ raise SourceManifestError(f"{context}.annotation_input must not be YAML")
+
+ expected_count = record.get("expected_count")
+ if expected_count is not None and (
+ isinstance(expected_count, bool)
+ or not isinstance(expected_count, int)
+ or expected_count < 0
+ ):
+ raise SourceManifestError(f"{context}.expected_count must be null or a nonnegative integer")
+
+ redistribution = record.get("redistribution_authorized")
+ if not isinstance(redistribution, bool):
+ raise SourceManifestError(
+ f"{context}.redistribution_authorized must be an explicit boolean"
+ )
+
+ evaluation = _mapping(record, "evaluation", context=context)
+ if not evaluation:
+ raise SourceManifestError(f"{context}.evaluation must be a nonempty JSON object")
+ preprocessing = _mapping(record, "preprocessing", context=context)
+ legacy_environment = _mapping(record, "legacy_environment", context=context)
+ for name in legacy_environment:
+ if not _ENVIRONMENT_NAME_RE.fullmatch(name):
+ raise SourceManifestError(
+ f"{context}.legacy_environment names must use uppercase letters, "
+ "digits, and underscores"
+ )
+ metadata = _mapping(record, "metadata", context=context)
+ media_roots, preprocessed_roots = _root_mapping(
+ record,
+ manifest_path.parent,
+ context=context,
+ )
+
+ try:
+ json.dumps(
+ {
+ "evaluation": evaluation,
+ "preprocessing": preprocessing,
+ "legacy_environment": legacy_environment,
+ "metadata": metadata,
+ },
+ ensure_ascii=False,
+ allow_nan=False,
+ )
+ except (TypeError, ValueError) as error:
+ raise SourceManifestError(f"{context}: metadata must be strict JSON: {error}") from error
+
+ license_name = _required_text(record, "license", context=context)
+ if license_name.startswith(("/", "~/", "file://")) or re.match(
+ r"^[A-Za-z]:[\\/]", license_name
+ ):
+ raise SourceManifestError(f"{context}.license must not contain a local path")
+
+ return EvaluationSource(
+ benchmark=benchmark,
+ eval_task=eval_task,
+ split=split,
+ family=family,
+ adapter=adapter,
+ annotation_input=annotation,
+ media_roots=media_roots,
+ preprocessed_roots=preprocessed_roots,
+ expected_count=expected_count,
+ license=license_name,
+ source_url=_source_url(record, context=context),
+ redistribution_authorized=redistribution,
+ evaluation=evaluation,
+ preprocessing=preprocessing,
+ legacy_environment=legacy_environment,
+ metadata=metadata,
+ manifest_index=index,
+ )
+
+
+def load_source_manifest(path: PathLike) -> list[EvaluationSource]:
+ """Load a local JSONL source manifest, resolving only local input paths."""
+
+ manifest_path = Path(path).expanduser()
+ if manifest_path.suffix.casefold() not in {".jsonl", ".ndjson"}:
+ raise SourceManifestError("evaluation source manifest must be JSONL, not YAML or JSON")
+ manifest_path = manifest_path.resolve()
+ if not manifest_path.is_file():
+ raise SourceManifestError(f"source manifest does not exist: {manifest_path}")
+
+ sources: list[EvaluationSource] = []
+ datasets: dict[tuple[str, str], int] = {}
+ with manifest_path.open("r", encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, start=1):
+ if not line.strip():
+ continue
+ try:
+ record = json.loads(line)
+ except json.JSONDecodeError as error:
+ raise SourceManifestError(
+ f"{manifest_path}:{line_number}: invalid JSON: {error}"
+ ) from error
+ if not isinstance(record, Mapping):
+ raise SourceManifestError(
+ f"{manifest_path}:{line_number}: source record must be a JSON object"
+ )
+ source = _source_from_record(record, manifest_path, line_number)
+ key = (source.benchmark, source.split)
+ previous = datasets.get(key)
+ if previous is not None:
+ raise SourceManifestError(
+ f"{manifest_path}:{line_number}: duplicate source {key[0]}/{key[1]} "
+ f"(first seen at line {previous})"
+ )
+ datasets[key] = line_number
+ sources.append(source)
+ if not sources:
+ raise SourceManifestError(f"{manifest_path}: source manifest is empty")
+ return sources
+
+
+load_evaluation_source_manifest = load_source_manifest
+load_evaluation_sources = load_source_manifest
+EvaluationSourceSpec = EvaluationSource
+SourceManifestRecord = EvaluationSourceRecord
+
+
+__all__ = [
+ "EvaluationSource",
+ "EvaluationSourceRecord",
+ "EvaluationSourceSpec",
+ "GENERIC_ADAPTERS",
+ "SOURCE_ADAPTERS",
+ "SourceManifestError",
+ "SourceManifestRecord",
+ "load_evaluation_source_manifest",
+ "load_evaluation_sources",
+ "load_source_manifest",
+]
diff --git a/code/orarl/evaluation/staging.py b/code/orarl/evaluation/staging.py
new file mode 100644
index 0000000000000000000000000000000000000000..00d5441b6fe2d8851dabe3a1bf1553057ad095ab
--- /dev/null
+++ b/code/orarl/evaluation/staging.py
@@ -0,0 +1,1918 @@
+"""Read-only inventory and atomic staging for canonical evaluation data."""
+
+from __future__ import annotations
+
+import json
+import math
+import os
+import re
+import shutil
+import tempfile
+import uuid
+from collections.abc import Iterable, Mapping, Sequence
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path, PurePosixPath
+from typing import Any, TypedDict
+
+from .card import (
+ DATASET_CARD_FILENAME,
+ DEFAULT_DATASET_REPO_ID,
+ DEFAULT_INDEX_REPO_ID,
+ GIT_ATTRIBUTES_FILENAME,
+ DatasetCardError,
+ render_index_card,
+ validate_huggingface_metadata,
+ write_huggingface_metadata,
+)
+from .converters import ConvertedSource, PlannedAsset, convert_sources
+from .layout import (
+ ANNOTATIONS_DIRECTORY,
+ ARTIFACTS_DIRECTORY,
+ ASSET_MANIFEST_FILENAME,
+ MANIFEST_FILENAME,
+ MEDIA_DIRECTORY,
+ LayoutError,
+ annotation_path,
+ artifact_directory,
+ benchmark_directory,
+ media_directory,
+ sha256_file,
+ validate_artifact_path,
+ validate_dataset_id,
+ validate_media_path,
+ validate_repository_path,
+ validate_sha256,
+)
+from .manifest import (
+ ManifestError,
+ load_dataset_manifest,
+ validate_dataset_manifest,
+ validate_evaluation_repository,
+)
+from .schema import (
+ EVALUATION_SCHEMA_VERSION,
+ declared_repository_paths,
+ evaluation_asset_paths,
+)
+from .sources import EvaluationSource, load_source_manifest
+
+_PATH_KEY_RE = re.compile(
+ r"(?:^|_)(?:artifact|artifacts|base|cfg|checkpoint|ckpt|dir|directory|file|"
+ r"files|image|images|media|path|paths|prefix|root|subtitle|"
+ r"subtitles|tensor|tensors|video|videos)$"
+)
+_WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]")
+_ASSET_KINDS = frozenset({"annotations", "artifacts", "images", "subtitles", "videos"})
+_STAGED_DIRECTORIES = (ANNOTATIONS_DIRECTORY, MEDIA_DIRECTORY, ARTIFACTS_DIRECTORY)
+_DROP = object()
+
+
+class _RequiredAssetManifestRecord(TypedDict):
+ path: str
+ bytes: int
+ kind: str
+ benchmark: str
+ license: str
+
+
+class AssetManifestRecord(_RequiredAssetManifestRecord, total=False):
+ """One deterministic path-and-size record in ``assets.jsonl``."""
+
+ sha256: str
+
+
+class StagingError(ValueError):
+ """Raised when inventory, build, or staged validation fails."""
+
+
+def _json_bytes(value: Any) -> bytes:
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ allow_nan=False,
+ ).encode("utf-8")
+
+
+def _atomic_write_jsonl(path: Path, records: Iterable[Mapping[str, Any]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{path.name}.",
+ suffix=".tmp",
+ dir=str(path.parent),
+ )
+ try:
+ with os.fdopen(descriptor, "wb") as handle:
+ for record in records:
+ handle.write(_json_bytes(record))
+ handle.write(b"\n")
+ os.replace(temporary_name, path)
+ except Exception:
+ try:
+ os.unlink(temporary_name)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+def _load_raw_jsonl(path: Path, *, context: str) -> list[Mapping[str, Any]]:
+ records: list[Mapping[str, Any]] = []
+ try:
+ with path.open("r", encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, start=1):
+ if not line.strip():
+ continue
+ try:
+ record = json.loads(line)
+ except json.JSONDecodeError as error:
+ raise StagingError(
+ f"{context}:{line_number}: invalid JSON: {error}"
+ ) from error
+ if not isinstance(record, Mapping):
+ raise StagingError(
+ f"{context}:{line_number}: record must be a JSON object"
+ )
+ records.append(record)
+ except OSError as error:
+ raise StagingError(f"cannot read {context}: {error}") from error
+ return records
+
+
+def _is_path_key(key: object) -> bool:
+ return isinstance(key, str) and bool(_PATH_KEY_RE.search(key.casefold()))
+
+
+def _canonical_profile_path(value: str) -> bool:
+ return value.startswith(
+ (
+ f"{ANNOTATIONS_DIRECTORY}/",
+ f"{MEDIA_DIRECTORY}/",
+ f"{ARTIFACTS_DIRECTORY}/",
+ )
+ )
+
+
+def _path_rewrites(
+ converted: ConvertedSource,
+ canonical_annotation: str,
+) -> tuple[dict[str, str], set[str]]:
+ source = converted.source
+ rewrites: dict[str, str] = {
+ os.path.normcase(str(source.annotation)): canonical_annotation,
+ os.path.normcase(str(source.annotation.resolve())): canonical_annotation,
+ }
+ directories: set[str] = set()
+ for name, root in source.media_roots.items():
+ destination = (
+ media_directory(source.benchmark)
+ if name == "default"
+ else media_directory(source.benchmark, name)
+ )
+ rewrites[os.path.normcase(str(root))] = destination
+ try:
+ rewrites[os.path.normcase(str(root.resolve()))] = destination
+ except OSError:
+ pass
+ directories.add(destination)
+ for root in source.preprocessed_roots.values():
+ destination = artifact_directory(source.benchmark)
+ rewrites[os.path.normcase(str(root))] = destination
+ try:
+ rewrites[os.path.normcase(str(root.resolve()))] = destination
+ except OSError:
+ pass
+ directories.add(destination)
+ for asset in converted.assets:
+ if asset.source_path is None:
+ continue
+ rewrites[os.path.normcase(str(asset.source_path))] = asset.repository_path
+ try:
+ rewrites[os.path.normcase(str(asset.source_path.resolve()))] = asset.repository_path
+ except OSError:
+ pass
+ return rewrites, directories
+
+
+def _rewrite_profile_path(
+ value: Any,
+ *,
+ source: EvaluationSource,
+ rewrites: Mapping[str, str],
+ key: str,
+) -> Any:
+ if isinstance(value, str):
+ if _canonical_profile_path(value):
+ try:
+ return validate_repository_path(value)
+ except LayoutError:
+ return _DROP
+ if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", value):
+ return _DROP
+ raw_path = Path(os.path.expanduser(value))
+ if raw_path.is_absolute():
+ candidates = [raw_path]
+ else:
+ candidates = [
+ source.annotation.parent / raw_path,
+ source.preprocessed_root(key) / raw_path,
+ *(root / raw_path for root in source.media_roots.values()),
+ ]
+ for candidate in candidates:
+ local = Path(os.path.abspath(os.path.normpath(str(candidate))))
+ rewritten = rewrites.get(os.path.normcase(str(local)))
+ if rewritten is not None:
+ return rewritten
+ try:
+ rewritten = rewrites.get(os.path.normcase(str(local.resolve())))
+ except OSError:
+ rewritten = None
+ if rewritten is not None:
+ return rewritten
+ return _DROP
+ if isinstance(value, Mapping):
+ converted: dict[str, Any] = {}
+ for raw_key, item in value.items():
+ if not isinstance(raw_key, str) or _private_source_string(raw_key, source):
+ continue
+ rewritten = _rewrite_profile_path(
+ item,
+ source=source,
+ rewrites=rewrites,
+ key=raw_key,
+ )
+ if rewritten is not _DROP:
+ converted[raw_key] = rewritten
+ return converted
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ converted_items = []
+ for item in value:
+ rewritten = _rewrite_profile_path(
+ item,
+ source=source,
+ rewrites=rewrites,
+ key=key,
+ )
+ if rewritten is not _DROP:
+ converted_items.append(rewritten)
+ return converted_items
+ return _DROP
+
+
+def _private_source_string(value: str, source: EvaluationSource) -> bool:
+ if value.startswith(("/", "~/", "file://")) or _WINDOWS_ABSOLUTE_RE.match(value):
+ return True
+ roots = {
+ str(source.annotation),
+ str(source.annotation.parent),
+ *(str(path) for path in source.media_roots.values()),
+ *(str(path) for path in source.preprocessed_roots.values()),
+ }
+ return any(root and root in value for root in roots)
+
+
+def _portable_profile(
+ value: Any,
+ *,
+ source: EvaluationSource,
+ rewrites: Mapping[str, str],
+ path_context: str | None = None,
+) -> Any:
+ if path_context:
+ return _rewrite_profile_path(
+ value,
+ source=source,
+ rewrites=rewrites,
+ key=path_context,
+ )
+ if value is None or isinstance(value, (bool, int)):
+ return value
+ if isinstance(value, float):
+ return value if math.isfinite(value) else _DROP
+ if isinstance(value, str):
+ return _DROP if _private_source_string(value, source) else value
+ if isinstance(value, Mapping):
+ converted: dict[str, Any] = {}
+ for raw_key, item in value.items():
+ if not isinstance(raw_key, str) or _private_source_string(raw_key, source):
+ continue
+ portable = _portable_profile(
+ item,
+ source=source,
+ rewrites=rewrites,
+ path_context=raw_key if _is_path_key(raw_key) else None,
+ )
+ if portable is not _DROP:
+ converted[raw_key] = portable
+ return converted
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ converted_items = []
+ for item in value:
+ portable = _portable_profile(
+ item,
+ source=source,
+ rewrites=rewrites,
+ )
+ if portable is not _DROP:
+ converted_items.append(portable)
+ return converted_items
+ return _DROP
+
+
+def _converted_inventory(converted: ConvertedSource) -> dict[str, Any]:
+ assets = {asset.repository_path: asset for asset in converted.assets}
+ missing = [asset for asset in assets.values() if asset.missing]
+ existing = [asset for asset in assets.values() if not asset.missing]
+ expected = converted.source.expected_count
+ return {
+ "adapter": converted.source.adapter,
+ "benchmark": converted.source.benchmark,
+ "split": converted.source.split,
+ "eval_task": converted.source.eval_task,
+ "source_rows": converted.source_rows,
+ "rows": len(converted.rows),
+ "expected_count": expected,
+ "count_matches": None if expected is None else len(converted.rows) == expected,
+ "referenced_assets": len(assets),
+ "existing_assets": len(existing),
+ "missing_assets": len(missing),
+ "bytes": sum(asset.bytes for asset in existing),
+ "redistribution_authorized": converted.source.redistribution_authorized,
+ }
+
+
+def _inventory_from_converted(
+ converted: Sequence[ConvertedSource],
+ *,
+ checksums: bool = False,
+) -> dict[str, Any]:
+ source_inventory = [_converted_inventory(item) for item in converted]
+
+ unique_assets: dict[str, PlannedAsset] = {}
+ for item in converted:
+ for asset in item.assets:
+ unique_assets.setdefault(asset.repository_path, asset)
+ unique_existing = [asset for asset in unique_assets.values() if not asset.missing]
+ unique_missing = [asset for asset in unique_assets.values() if asset.missing]
+ expected_counts = [
+ item.source.expected_count for item in converted if item.source.expected_count is not None
+ ]
+ unlocked_sources = sum(item.source.expected_count is None for item in converted)
+ return {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "asset_checksums": checksums,
+ "sources": source_inventory,
+ "totals": {
+ "sources": len(converted),
+ "source_rows": sum(item.source_rows for item in converted),
+ "rows": sum(len(item.rows) for item in converted),
+ "expected_count": None if unlocked_sources else sum(expected_counts),
+ "locked_expected_count": sum(expected_counts),
+ "unlocked_sources": unlocked_sources,
+ "referenced_assets": len(unique_assets),
+ "existing_assets": len(unique_existing),
+ "missing_assets": len(unique_missing),
+ "bytes": sum(asset.bytes for asset in unique_existing),
+ "count_mismatches": sum(
+ item.source.expected_count is not None
+ and len(item.rows) != item.source.expected_count
+ for item in converted
+ ),
+ "unauthorized_sources": sum(
+ not item.source.redistribution_authorized for item in converted
+ ),
+ },
+ }
+
+
+def _select_source_tasks(
+ sources: Sequence[EvaluationSource],
+ tasks: Iterable[str] | None,
+) -> list[EvaluationSource]:
+ requested = {
+ str(task).strip().casefold()
+ for task in (tasks or ())
+ if str(task).strip()
+ }
+ if not requested:
+ return list(sources)
+ selected = [
+ source
+ for source in sources
+ if source.eval_task.casefold() in requested
+ ]
+ covered = {source.eval_task.casefold() for source in selected}
+ missing = sorted(requested - covered)
+ if missing:
+ raise StagingError(
+ "source manifest has no records for task(s): " + ", ".join(missing)
+ )
+ return selected
+
+
+def inventory_evaluation_sources(
+ source_manifest: str | os.PathLike[str],
+ *,
+ tasks: Iterable[str] | None = None,
+ workers: int = 1,
+ checksums: bool = False,
+) -> dict[str, Any]:
+ """Read source rows and assets without creating or modifying output paths."""
+
+ sources = _select_source_tasks(load_source_manifest(source_manifest), tasks)
+ return _inventory_from_converted(
+ convert_sources(
+ sources,
+ workers=workers,
+ hash_assets=checksums,
+ ),
+ checksums=checksums,
+ )
+
+
+def _locked_source_record(converted: ConvertedSource) -> dict[str, Any]:
+ source = converted.source
+ record: dict[str, Any] = {
+ "benchmark": source.benchmark,
+ "eval_task": source.eval_task,
+ "split": source.split,
+ "family": source.family,
+ "adapter": source.adapter,
+ "annotation_input": str(source.annotation.resolve()),
+ "expected_count": len(converted.rows),
+ "license": source.license,
+ "source_url": source.source_url,
+ "redistribution_authorized": source.redistribution_authorized,
+ "evaluation": dict(source.evaluation),
+ }
+ if source.media_roots:
+ record["media_roots"] = {
+ name: str(path.resolve()) for name, path in sorted(source.media_roots.items())
+ }
+ if source.preprocessed_roots:
+ record["preprocessed_roots"] = {
+ name: str(path.resolve()) for name, path in sorted(source.preprocessed_roots.items())
+ }
+ for field, value in (
+ ("preprocessing", source.preprocessing),
+ ("legacy_environment", source.legacy_environment),
+ ("metadata", source.metadata),
+ ):
+ if value:
+ record[field] = dict(value)
+ return record
+
+
+def write_locked_source_manifest(
+ source_manifest: str | os.PathLike[str],
+ output_path: str | os.PathLike[str],
+ *,
+ overwrite: bool = False,
+ tasks: Iterable[str] | None = None,
+ workers: int = 1,
+ checksums: bool = False,
+) -> dict[str, Any]:
+ """Write a deterministic private manifest with actual converted row counts."""
+
+ manifest_path = Path(source_manifest).expanduser().resolve()
+ output = Path(output_path).expanduser().resolve(strict=False)
+ if output.exists() and not overwrite:
+ raise FileExistsError(f"refusing to overwrite locked source manifest: {output}")
+ sources = _select_source_tasks(load_source_manifest(manifest_path), tasks)
+ converted = convert_sources(
+ sources,
+ workers=workers,
+ hash_assets=checksums,
+ )
+ records = [_locked_source_record(item) for item in converted]
+ records.sort(key=lambda item: (str(item["benchmark"]), str(item["split"])))
+ output.parent.mkdir(parents=True, exist_ok=True)
+ _atomic_write_jsonl(output, records)
+ return {
+ "path": str(output),
+ "sources": len(records),
+ "rows": sum(int(record["expected_count"]) for record in records),
+ }
+
+
+def inventory_and_write_locked_manifest(
+ source_manifest: str | os.PathLike[str],
+ output_path: str | os.PathLike[str],
+ *,
+ overwrite: bool = False,
+ tasks: Iterable[str] | None = None,
+ workers: int = 1,
+ checksums: bool = False,
+) -> dict[str, Any]:
+ """Inventory once and write the same conversion's actual counts."""
+
+ manifest_path = Path(source_manifest).expanduser().resolve()
+ output = Path(output_path).expanduser().resolve(strict=False)
+ if output.exists() and not overwrite:
+ raise FileExistsError(f"refusing to overwrite locked source manifest: {output}")
+ sources = _select_source_tasks(load_source_manifest(manifest_path), tasks)
+ converted = convert_sources(
+ sources,
+ workers=workers,
+ hash_assets=checksums,
+ )
+ inventory = _inventory_from_converted(
+ converted,
+ checksums=checksums,
+ )
+ records = [_locked_source_record(item) for item in converted]
+ records.sort(key=lambda item: (str(item["benchmark"]), str(item["split"])))
+ output.parent.mkdir(parents=True, exist_ok=True)
+ _atomic_write_jsonl(output, records)
+ return {
+ **inventory,
+ "locked_manifest": {
+ "path": str(output),
+ "sources": len(records),
+ "rows": sum(int(record["expected_count"]) for record in records),
+ },
+ }
+
+
+def _merge_assets(converted: Sequence[ConvertedSource]) -> dict[str, PlannedAsset]:
+ merged: dict[str, PlannedAsset] = {}
+ for item in converted:
+ for asset in item.assets:
+ previous = merged.get(asset.repository_path)
+ if previous is not None:
+ if (
+ previous.sha256 != asset.sha256
+ or previous.bytes != asset.bytes
+ or previous.kind != asset.kind
+ ):
+ raise StagingError(
+ f"conflicting staged asset definitions: {asset.repository_path}"
+ )
+ if previous.license != asset.license:
+ raise StagingError(
+ f"conflicting licenses for shared asset: {asset.repository_path}"
+ )
+ continue
+ merged[asset.repository_path] = asset
+ return merged
+
+
+def _materialize_asset(
+ root: Path,
+ asset: PlannedAsset,
+ *,
+ copy_mode: str,
+) -> str:
+ destination = root.joinpath(*PurePosixPath(asset.repository_path).parts)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if destination.exists() or destination.is_symlink():
+ raise StagingError(f"duplicate staged output path: {asset.repository_path}")
+ if asset.missing:
+ source_label = asset.source_path.name if asset.source_path is not None else "asset"
+ raise StagingError(
+ f"referenced source asset is missing for {asset.benchmark}: {source_label}"
+ )
+ if asset.content is not None:
+ destination.write_bytes(asset.content)
+ return "copied"
+ if asset.source_path is None:
+ raise StagingError(f"asset has no materialization source: {asset.repository_path}")
+
+ source_path = asset.source_path.resolve(strict=True)
+ if copy_mode == "hardlink":
+ try:
+ os.link(source_path, destination, follow_symlinks=True)
+ return "hardlinked"
+ except OSError:
+ pass
+ shutil.copyfile(source_path, destination, follow_symlinks=True)
+ return "copied"
+
+
+def _asset_record(asset: PlannedAsset) -> AssetManifestRecord:
+ record: AssetManifestRecord = {
+ "path": asset.repository_path,
+ "bytes": asset.bytes,
+ "kind": asset.kind,
+ "benchmark": asset.benchmark,
+ "license": asset.license,
+ }
+ if asset.sha256 is not None:
+ record["sha256"] = asset.sha256
+ return record
+
+
+def _annotation_asset(
+ path: Path,
+ *,
+ repository_path: str,
+ source: EvaluationSource,
+ checksums: bool = False,
+) -> AssetManifestRecord:
+ record: AssetManifestRecord = {
+ "path": repository_path,
+ "bytes": path.stat().st_size,
+ "kind": "annotations",
+ "benchmark": source.benchmark,
+ "license": source.license,
+ }
+ if checksums:
+ record["sha256"] = sha256_file(path)
+ return record
+
+
+def _manifest_record(
+ converted: ConvertedSource,
+ annotation_record: AssetManifestRecord,
+) -> tuple[dict[str, Any], set[str]]:
+ source = converted.source
+ if source.expected_count is None:
+ raise StagingError(
+ f"{source.benchmark}/{source.split}: expected_count must be locked before build"
+ )
+ canonical_annotation = str(annotation_record["path"])
+ rewrites, profile_directories = _path_rewrites(converted, canonical_annotation)
+
+ evaluation = _portable_profile(
+ source.evaluation,
+ source=source,
+ rewrites=rewrites,
+ )
+ if not isinstance(evaluation, Mapping) or not evaluation:
+ raise StagingError(f"{source.benchmark}/{source.split}: evaluation profile became empty")
+ preprocessing = _portable_profile(
+ source.preprocessing,
+ source=source,
+ rewrites=rewrites,
+ )
+ legacy_environment = _portable_profile(
+ source.legacy_environment,
+ source=source,
+ rewrites=rewrites,
+ )
+ metadata = _portable_profile(
+ source.metadata,
+ source=source,
+ rewrites=rewrites,
+ )
+
+ existing_assets = [asset for asset in converted.assets if not asset.missing]
+ media_paths = {
+ str(PurePosixPath(asset.repository_path).parent)
+ for asset in existing_assets
+ if asset.kind in {"images", "subtitles", "videos"}
+ }
+ artifact_paths = {
+ artifact_directory(source.benchmark)
+ for asset in existing_assets
+ if asset.kind == "artifacts"
+ }
+ for directory in profile_directories:
+ if directory.startswith(f"{MEDIA_DIRECTORY}/"):
+ media_paths.add(directory)
+ elif directory.startswith(f"{ARTIFACTS_DIRECTORY}/"):
+ artifact_paths.add(directory)
+ record: dict[str, Any] = {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "benchmark": source.benchmark,
+ "split": source.split,
+ "task": source.eval_task,
+ "family": source.family,
+ "annotation_path": canonical_annotation,
+ "media_paths": sorted(media_paths),
+ "artifact_paths": sorted(artifact_paths),
+ "expected_count": source.expected_count,
+ "license": source.license,
+ "source_url": source.source_url,
+ "redistribution_authorized": source.redistribution_authorized,
+ "evaluation": dict(evaluation),
+ }
+ annotation_checksum = annotation_record.get("sha256")
+ if isinstance(annotation_checksum, str):
+ record["checksums"] = {canonical_annotation: annotation_checksum}
+ if isinstance(preprocessing, Mapping) and preprocessing:
+ record["preprocessing"] = dict(preprocessing)
+ if isinstance(legacy_environment, Mapping) and legacy_environment:
+ record["legacy_environment"] = dict(legacy_environment)
+ if isinstance(metadata, Mapping) and metadata:
+ record["metadata"] = dict(metadata)
+ return record, profile_directories
+
+
+def _overlaps(left: Path, right: Path) -> bool:
+ return left == right or left in right.parents or right in left.parents
+
+
+def _protect_sources(
+ target: Path,
+ manifest_path: Path,
+ sources: Sequence[EvaluationSource],
+) -> None:
+ protected = [manifest_path]
+ for source in sources:
+ protected.append(source.annotation)
+ protected.extend(source.media_roots.values())
+ protected.extend(source.preprocessed_roots.values())
+ for path in protected:
+ normalized = path.resolve(strict=False)
+ if _overlaps(target, normalized):
+ raise StagingError(f"output target must not overlap a source path: {normalized}")
+
+
+def _remove_path(path: Path) -> None:
+ if path.is_dir() and not path.is_symlink():
+ shutil.rmtree(path)
+ else:
+ path.unlink(missing_ok=True)
+
+
+def _publish_directory(staging: Path, target: Path, *, overwrite: bool) -> None:
+ if target.exists() or target.is_symlink():
+ if not overwrite:
+ raise FileExistsError(f"refusing to overwrite existing evaluation repository: {target}")
+ if target.is_symlink():
+ raise StagingError(f"refusing to overwrite a symbolic-link target: {target}")
+ backup = target.parent / f".{target.name}.backup-{uuid.uuid4().hex}"
+ os.replace(target, backup)
+ try:
+ os.replace(staging, target)
+ except Exception:
+ os.replace(backup, target)
+ raise
+ _remove_path(backup)
+ return
+ os.replace(staging, target)
+
+
+def build_evaluation_repository(
+ source_manifest: str | os.PathLike[str],
+ output_root: str | os.PathLike[str],
+ *,
+ copy_mode: str = "copy",
+ overwrite: bool = False,
+ workers: int = 1,
+ checksums: bool = False,
+) -> dict[str, Any]:
+ """Atomically build a deterministic canonical evaluation repository."""
+
+ if copy_mode not in {"copy", "hardlink"}:
+ raise StagingError("copy_mode must be 'copy' or 'hardlink'")
+ target = Path(output_root).expanduser().resolve(strict=False)
+ if target.exists() or target.is_symlink():
+ if not overwrite:
+ raise FileExistsError(f"refusing to overwrite existing evaluation repository: {target}")
+ if target.is_symlink():
+ raise StagingError(f"refusing to overwrite a symbolic-link target: {target}")
+
+ manifest_path = Path(source_manifest).expanduser().resolve()
+ sources = load_source_manifest(manifest_path)
+ _protect_sources(target, manifest_path, sources)
+ converted = convert_sources(
+ sources,
+ workers=workers,
+ hash_assets=checksums,
+ )
+ for item in converted:
+ actual = len(item.rows)
+ expected = item.source.expected_count
+ if expected is None:
+ raise StagingError(
+ f"{item.source.benchmark}/{item.source.split}: expected_count is not locked; "
+ "run inventory --write-locked-manifest first"
+ )
+ if actual != expected:
+ raise StagingError(
+ f"{item.source.benchmark}/{item.source.split}: expected "
+ f"{expected} rows, converted {actual}"
+ )
+ if not item.source.redistribution_authorized:
+ raise StagingError(
+ f"{item.source.benchmark}/{item.source.split}: redistribution is not authorized"
+ )
+ if item.missing_assets:
+ names = sorted(
+ asset.source_path.name
+ for asset in item.missing_assets
+ if asset.source_path is not None
+ )
+ detail = ", ".join(names[:5]) or "unknown asset"
+ raise StagingError(
+ f"{item.source.benchmark}/{item.source.split}: "
+ f"{len(item.missing_assets)} referenced asset(s) are missing: {detail}"
+ )
+
+ merged_assets = _merge_assets(converted)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ staging = Path(
+ tempfile.mkdtemp(
+ prefix=f".{target.name}.staging-",
+ dir=str(target.parent),
+ )
+ )
+ materialization_counts = {"copied": 0, "hardlinked": 0}
+ try:
+ ordered_assets = sorted(
+ merged_assets.values(),
+ key=lambda item: item.repository_path,
+ )
+
+ def _stage_asset(asset: PlannedAsset) -> str:
+ return _materialize_asset(staging, asset, copy_mode=copy_mode)
+
+ if workers == 1:
+ actions = map(_stage_asset, ordered_assets)
+ else:
+ executor = ThreadPoolExecutor(max_workers=workers)
+ actions = executor.map(_stage_asset, ordered_assets)
+ try:
+ for action in actions:
+ materialization_counts[action] += 1
+ finally:
+ if workers != 1:
+ executor.shutdown(wait=True, cancel_futures=True)
+
+ annotation_records: dict[tuple[str, str], AssetManifestRecord] = {}
+ asset_records: list[AssetManifestRecord] = [
+ _asset_record(asset)
+ for asset in sorted(merged_assets.values(), key=lambda item: item.repository_path)
+ ]
+ for item in converted:
+ relative = annotation_path(item.source.benchmark, item.source.split)
+ annotation = staging.joinpath(*PurePosixPath(relative).parts)
+ _atomic_write_jsonl(annotation, item.rows)
+ annotation_record = _annotation_asset(
+ annotation,
+ repository_path=relative,
+ source=item.source,
+ checksums=checksums,
+ )
+ annotation_records[(item.source.benchmark, item.source.split)] = annotation_record
+ asset_records.append(annotation_record)
+
+ manifest_records: list[dict[str, Any]] = []
+ declared_directories: set[str] = set()
+ for item in converted:
+ key = (item.source.benchmark, item.source.split)
+ record, directories = _manifest_record(item, annotation_records[key])
+ manifest_records.append(record)
+ declared_directories.update(directories)
+ manifest_records.sort(key=lambda item: (str(item["benchmark"]), str(item["split"])))
+ asset_records.sort(key=lambda item: str(item["path"]))
+
+ for relative in sorted(declared_directories):
+ staging.joinpath(*PurePosixPath(relative).parts).mkdir(
+ parents=True,
+ exist_ok=True,
+ )
+ _atomic_write_jsonl(staging / MANIFEST_FILENAME, manifest_records)
+ _atomic_write_jsonl(staging / ASSET_MANIFEST_FILENAME, asset_records)
+ write_huggingface_metadata(staging)
+ if checksums:
+ summary = validate_staged_repository(staging, checksums=True)
+ else:
+ summary = {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "datasets": len(manifest_records),
+ "rows": sum(len(item.rows) for item in converted),
+ "assets": len(asset_records),
+ "bytes": sum(int(record["bytes"]) for record in asset_records),
+ }
+ _publish_directory(staging, target, overwrite=overwrite)
+ except Exception:
+ if staging.exists() or staging.is_symlink():
+ _remove_path(staging)
+ raise
+
+ return {
+ **summary,
+ "copy_mode": copy_mode,
+ "asset_checksums": checksums,
+ **materialization_counts,
+ }
+
+
+def _reconstruct_asset_manifest(
+ root: Path,
+ datasets: Sequence[Mapping[str, Any]],
+) -> list[AssetManifestRecord]:
+ """Reconstruct a deleted asset manifest without rehashing addressed payloads."""
+
+ licenses: dict[str, str] = {}
+ for dataset in datasets:
+ benchmark = str(dataset["benchmark"])
+ license_name = str(dataset["license"])
+ previous = licenses.get(benchmark)
+ if previous is not None and previous != license_name:
+ raise StagingError(
+ f"cannot reconstruct assets with conflicting licenses for {benchmark}"
+ )
+ licenses[benchmark] = license_name
+
+ records: list[AssetManifestRecord] = []
+ for directory in _STAGED_DIRECTORIES:
+ asset_root = root / directory
+ if not asset_root.exists():
+ continue
+ for path in sorted(asset_root.rglob("*")):
+ if path.is_symlink():
+ raise StagingError(f"staged asset must not be a symlink: {path}")
+ if not path.is_file():
+ continue
+ relative = path.relative_to(root).as_posix()
+ parts = PurePosixPath(relative).parts
+ if len(parts) < 3:
+ raise StagingError(f"cannot infer asset metadata from path: {relative}")
+ candidates = [
+ benchmark
+ for benchmark in licenses
+ if relative.startswith(
+ f"{directory}/{benchmark_directory(benchmark)}/"
+ )
+ ]
+ if len(candidates) != 1:
+ raise StagingError(
+ f"asset has no dataset license declaration: {relative}"
+ )
+ benchmark = candidates[0]
+ license_name = licenses[benchmark]
+ if directory == ANNOTATIONS_DIRECTORY:
+ kind = "annotations"
+ elif directory == ARTIFACTS_DIRECTORY:
+ kind = "artifacts"
+ else:
+ media_parts = PurePosixPath(media_directory(benchmark)).parts
+ if (
+ len(parts) <= len(media_parts)
+ or parts[len(media_parts)] not in {"images", "subtitles", "videos"}
+ ):
+ raise StagingError(f"cannot infer media kind from path: {relative}")
+ kind = parts[len(media_parts)]
+ records.append(
+ {
+ "path": relative,
+ "bytes": path.stat().st_size,
+ "kind": kind,
+ "benchmark": benchmark,
+ "license": license_name,
+ }
+ )
+ records.sort(key=lambda item: str(item["path"]))
+ return validate_asset_manifest(records)
+
+
+def _require_manifested_asset(
+ root: Path,
+ record: Mapping[str, Any],
+ *,
+ context: str,
+) -> Path:
+ relative = str(record["path"])
+ path = root.joinpath(*PurePosixPath(relative).parts)
+ if path.is_symlink() or not path.is_file():
+ raise StagingError(f"{context} asset is missing or a symlink: {relative}")
+ actual_bytes = path.stat().st_size
+ if actual_bytes != int(record["bytes"]):
+ raise StagingError(
+ f"{context} asset byte-size mismatch for {relative}: "
+ f"expected {record['bytes']}, got {actual_bytes}"
+ )
+ return path
+
+
+def merge_evaluation_repository(
+ source_root: str | os.PathLike[str],
+ target_root: str | os.PathLike[str],
+ *,
+ copy_mode: str = "hardlink",
+ repair_missing_asset_manifest: bool = False,
+ workers: int = 1,
+ replace_benchmarks: Iterable[str] = (),
+) -> dict[str, Any]:
+ """Incrementally add or explicitly replace benchmarks in an existing release."""
+
+ if copy_mode not in {"copy", "hardlink"}:
+ raise StagingError("copy_mode must be 'copy' or 'hardlink'")
+ if workers <= 0:
+ raise StagingError("workers must be positive")
+ source = Path(source_root).expanduser().resolve()
+ target = Path(target_root).expanduser().resolve()
+ if source == target or source in target.parents or target in source.parents:
+ raise StagingError("source and target evaluation repositories must be separate")
+ if not source.is_dir():
+ raise StagingError(f"source evaluation repository does not exist: {source}")
+ if not target.is_dir():
+ raise StagingError(f"target evaluation repository does not exist: {target}")
+
+ replacements = {
+ validate_dataset_id(value, context="replacement benchmark")
+ for value in replace_benchmarks
+ }
+ source_datasets = load_dataset_manifest(source)
+ source_assets = load_asset_manifest(source)
+ if replacements:
+ raw_target_datasets = _load_raw_jsonl(
+ target / MANIFEST_FILENAME,
+ context=str(target / MANIFEST_FILENAME),
+ )
+ legacy_datasets: list[Mapping[str, Any]] = []
+ current_datasets: list[Mapping[str, Any]] = []
+ for index, record in enumerate(raw_target_datasets, start=1):
+ benchmark = record.get("benchmark")
+ if benchmark in replacements:
+ validate_dataset_id(
+ benchmark,
+ context=f"legacy target dataset {index} benchmark",
+ )
+ validate_dataset_id(
+ record.get("split"),
+ context=f"legacy target dataset {index} split",
+ )
+ legacy_datasets.append(record)
+ else:
+ current_datasets.append(record)
+ target_datasets = [
+ *validate_dataset_manifest(
+ current_datasets,
+ context=str(target / MANIFEST_FILENAME),
+ ),
+ *legacy_datasets,
+ ]
+ else:
+ target_datasets = load_dataset_manifest(target)
+
+ target_asset_path = target / ASSET_MANIFEST_FILENAME
+ repaired = False
+ if target_asset_path.is_file():
+ if replacements:
+ raw_target_assets = _load_raw_jsonl(
+ target_asset_path,
+ context=str(target_asset_path),
+ )
+ legacy_assets: list[Mapping[str, Any]] = []
+ current_assets: list[Mapping[str, Any]] = []
+ for index, record in enumerate(raw_target_assets, start=1):
+ benchmark = record.get("benchmark")
+ if benchmark in replacements:
+ validate_dataset_id(
+ benchmark,
+ context=f"legacy target asset {index} benchmark",
+ )
+ validate_repository_path(
+ record.get("path"),
+ context=f"legacy target asset {index} path",
+ )
+ byte_count = record.get("bytes")
+ if (
+ isinstance(byte_count, bool)
+ or not isinstance(byte_count, int)
+ or byte_count < 0
+ ):
+ raise StagingError(
+ f"legacy target asset {index} bytes must be nonnegative"
+ )
+ if record.get("kind") not in _ASSET_KINDS:
+ raise StagingError(
+ f"legacy target asset {index} has unsupported kind"
+ )
+ legacy_assets.append(record)
+ else:
+ current_assets.append(record)
+ target_assets = [
+ *validate_asset_manifest(
+ current_assets,
+ context=str(target_asset_path),
+ ),
+ *legacy_assets,
+ ]
+ else:
+ target_assets = load_asset_manifest(target_asset_path)
+ elif repair_missing_asset_manifest:
+ if replacements:
+ raise StagingError(
+ "cannot reconstruct a legacy asset manifest during benchmark replacement"
+ )
+ target_assets = _reconstruct_asset_manifest(target, target_datasets)
+ repaired = True
+ else:
+ raise StagingError(
+ f"target asset manifest is missing: {target_asset_path}; "
+ "pass repair_missing_asset_manifest=True to reconstruct it"
+ )
+
+ source_benchmarks = {str(record["benchmark"]) for record in source_datasets}
+ unavailable = sorted(replacements - source_benchmarks)
+ if unavailable:
+ raise StagingError(
+ "replacement benchmark is absent from source repository: "
+ + ", ".join(unavailable)
+ )
+ for benchmark in sorted(replacements):
+ source_splits = {
+ str(record["split"])
+ for record in source_datasets
+ if str(record["benchmark"]) == benchmark
+ }
+ target_splits = {
+ str(record["split"])
+ for record in target_datasets
+ if str(record["benchmark"]) == benchmark
+ }
+ missing_splits = sorted(target_splits - source_splits)
+ if missing_splits:
+ raise StagingError(
+ f"replacement for {benchmark} must include every existing split; "
+ f"missing: {', '.join(missing_splits)}"
+ )
+
+ datasets = {
+ (str(record["benchmark"]), str(record["split"])): record
+ for record in target_datasets
+ if str(record["benchmark"]) not in replacements
+ }
+ for record in source_datasets:
+ key = (str(record["benchmark"]), str(record["split"]))
+ previous = datasets.get(key)
+ if previous is not None and previous != record:
+ raise StagingError(f"conflicting dataset definition: {key[0]}/{key[1]}")
+ datasets[key] = record
+
+ replaced_target_assets = [
+ record
+ for record in target_assets
+ if str(record["benchmark"]) in replacements
+ ]
+ replaced_target_assets_by_path = {
+ str(record["path"]): record for record in replaced_target_assets
+ }
+ for record in replaced_target_assets:
+ _require_manifested_asset(target, record, context="replaced target")
+ assets = {
+ str(record["path"]): record
+ for record in target_assets
+ if str(record["benchmark"]) not in replacements
+ }
+ for record in source_assets:
+ relative = str(record["path"])
+ previous = assets.get(relative)
+ if previous is not None and previous != record:
+ raise StagingError(f"conflicting asset definition: {relative}")
+ assets[relative] = record
+
+ def _source_entry(record: Mapping[str, Any]) -> tuple[str, Path]:
+ relative = str(record["path"])
+ return (
+ relative,
+ _require_manifested_asset(source, record, context="source"),
+ )
+
+ if workers == 1:
+ source_entries = map(_source_entry, source_assets)
+ else:
+ source_executor = ThreadPoolExecutor(max_workers=workers)
+ source_entries = source_executor.map(_source_entry, source_assets)
+ try:
+ source_paths = dict(source_entries)
+ finally:
+ if workers != 1:
+ source_executor.shutdown(wait=True, cancel_futures=True)
+
+ def _merge_asset(record: Mapping[str, Any]) -> str:
+ relative = str(record["path"])
+ destination = target.joinpath(*PurePosixPath(relative).parts)
+ if destination.exists() or destination.is_symlink():
+ replaced_record = replaced_target_assets_by_path.get(relative)
+ if replaced_record is not None and replaced_record != record:
+ _require_manifested_asset(
+ target,
+ replaced_record,
+ context="replaced target",
+ )
+ temporary = destination.with_name(
+ f".{destination.name}.merge-{uuid.uuid4().hex}"
+ )
+ try:
+ if copy_mode == "hardlink":
+ try:
+ os.link(
+ source_paths[relative],
+ temporary,
+ follow_symlinks=True,
+ )
+ action = "hardlinked"
+ except OSError:
+ shutil.copyfile(
+ source_paths[relative],
+ temporary,
+ follow_symlinks=True,
+ )
+ action = "copied"
+ else:
+ shutil.copyfile(
+ source_paths[relative],
+ temporary,
+ follow_symlinks=True,
+ )
+ action = "copied"
+ os.replace(temporary, destination)
+ return action
+ finally:
+ if temporary.exists() or temporary.is_symlink():
+ temporary.unlink()
+ _require_manifested_asset(target, record, context="existing target")
+ return "existing"
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if copy_mode == "hardlink":
+ try:
+ os.link(source_paths[relative], destination, follow_symlinks=True)
+ return "hardlinked"
+ except OSError:
+ pass
+ shutil.copyfile(source_paths[relative], destination, follow_symlinks=True)
+ return "copied"
+
+ counts = {"copied": 0, "hardlinked": 0, "existing": 0}
+ if workers == 1:
+ actions = map(_merge_asset, source_assets)
+ else:
+ executor = ThreadPoolExecutor(max_workers=workers)
+ actions = executor.map(_merge_asset, source_assets)
+ try:
+ for action in actions:
+ counts[action] += 1
+ finally:
+ if workers != 1:
+ executor.shutdown(wait=True, cancel_futures=True)
+
+ merged_datasets = sorted(
+ datasets.values(),
+ key=lambda item: (str(item["benchmark"]), str(item["split"])),
+ )
+ merged_assets = sorted(assets.values(), key=lambda item: str(item["path"]))
+ _atomic_write_jsonl(target / MANIFEST_FILENAME, merged_datasets)
+ _atomic_write_jsonl(target_asset_path, merged_assets)
+ write_huggingface_metadata(target)
+
+ retained_paths = {str(record["path"]) for record in merged_assets}
+ removed = 0
+ for record in replaced_target_assets:
+ relative = str(record["path"])
+ if relative in retained_paths:
+ continue
+ stale = target.joinpath(*PurePosixPath(relative).parts)
+ if stale.is_symlink() or not stale.is_file():
+ raise StagingError(f"replaced target asset disappeared: {relative}")
+ stale.unlink()
+ removed += 1
+ parent = stale.parent
+ while parent != target:
+ try:
+ parent.rmdir()
+ except OSError:
+ break
+ parent = parent.parent
+
+ return {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "datasets": len(merged_datasets),
+ "rows": sum(int(record["expected_count"]) for record in merged_datasets),
+ "assets": len(merged_assets),
+ "bytes": sum(int(record["bytes"]) for record in merged_assets),
+ "copy_mode": copy_mode,
+ "workers": workers,
+ "repaired_asset_manifest": repaired,
+ "replaced_benchmarks": sorted(replacements),
+ "removed": removed,
+ **counts,
+ }
+
+
+def _without_artifact_references(value: Any) -> Any:
+ if isinstance(value, str) and value.startswith(
+ (f"{ARTIFACTS_DIRECTORY}/", f"./{ARTIFACTS_DIRECTORY}/")
+ ):
+ return _DROP
+ if isinstance(value, Mapping):
+ cleaned: dict[str, Any] = {}
+ for key, item in value.items():
+ converted = _without_artifact_references(item)
+ if converted is not _DROP:
+ cleaned[str(key)] = converted
+ return cleaned
+ if isinstance(value, list):
+ return [
+ converted
+ for item in value
+ if (converted := _without_artifact_references(item)) is not _DROP
+ ]
+ return value
+
+
+def _public_dataset_record(record: Mapping[str, Any]) -> dict[str, Any]:
+ public = dict(record)
+ public["artifact_paths"] = []
+ if public.get("task") == "segmentation":
+ legacy = dict(public.get("legacy_environment", {}))
+ legacy["SEGMENTATION_VIDEO_READER"] = "decord"
+ setting = str(legacy.get("SEGMENTATION_SETTING", ""))
+ if setting and "readerdecord" not in setting:
+ marker = setting.rfind("-new")
+ legacy["SEGMENTATION_SETTING"] = (
+ f"{setting[:marker]}-readerdecord{setting[marker:]}"
+ if marker >= 0
+ else f"{setting}-readerdecord"
+ )
+ public["legacy_environment"] = legacy
+ preprocessing = dict(public.get("preprocessing", {}))
+ preprocessing["video_reader"] = "decord"
+ public["preprocessing"] = preprocessing
+ return public
+
+
+def export_public_evaluation_repository(
+ repository_root: str | os.PathLike[str],
+ output_root: str | os.PathLike[str],
+ *,
+ copy_mode: str = "hardlink",
+ overwrite: bool = False,
+ workers: int = 8,
+ checksums: bool = False,
+) -> dict[str, Any]:
+ """Export raw media and annotations while excluding processed artifacts."""
+
+ if copy_mode not in {"copy", "hardlink"}:
+ raise StagingError("copy_mode must be 'copy' or 'hardlink'")
+ if isinstance(workers, bool) or not isinstance(workers, int) or workers <= 0:
+ raise StagingError("workers must be a positive integer")
+
+ source = Path(repository_root).expanduser().resolve()
+ target = Path(output_root).expanduser().resolve(strict=False)
+ if source == target or source in target.parents or target in source.parents:
+ raise StagingError("public export and complete repository must be separate")
+ if target.exists() or target.is_symlink():
+ if not overwrite:
+ raise FileExistsError(
+ f"refusing to overwrite public evaluation repository: {target}"
+ )
+ if target.is_symlink():
+ raise StagingError("refusing to overwrite a symbolic-link target")
+
+ try:
+ validate_evaluation_repository(
+ source,
+ require_redistribution_authorized=True,
+ )
+ source_datasets = load_dataset_manifest(source)
+ source_assets = load_asset_manifest(
+ source,
+ repository_root=source,
+ checksums=checksums,
+ )
+ validate_huggingface_metadata(source)
+ except (DatasetCardError, ManifestError) as error:
+ raise StagingError(str(error)) from error
+ public_datasets = [
+ _public_dataset_record(record)
+ for record in source_datasets
+ ]
+ retained_assets = [
+ dict(record)
+ for record in source_assets
+ if record["kind"] not in {"annotations", "artifacts"}
+ ]
+ excluded_artifacts = [
+ record for record in source_assets if record["kind"] == "artifacts"
+ ]
+
+ target.parent.mkdir(parents=True, exist_ok=True)
+ staging = Path(
+ tempfile.mkdtemp(
+ prefix=f".{target.name}.staging-",
+ dir=str(target.parent),
+ )
+ )
+ counts = {"copied": 0, "hardlinked": 0}
+ try:
+ annotation_assets: list[dict[str, Any]] = []
+ for dataset in public_datasets:
+ relative = str(dataset["annotation_path"])
+ source_annotation = source.joinpath(*PurePosixPath(relative).parts)
+ rows = _load_raw_jsonl(
+ source_annotation,
+ context=f"public annotation {relative}",
+ )
+ cleaned_rows: list[Mapping[str, Any]] = []
+ for row in rows:
+ cleaned = _without_artifact_references(row)
+ if not isinstance(cleaned, Mapping):
+ raise StagingError(f"{relative}: cleaned row must remain an object")
+ if not cleaned.get("preprocessed"):
+ cleaned = {
+ key: value
+ for key, value in cleaned.items()
+ if key != "preprocessed"
+ }
+ cleaned_rows.append(cleaned)
+ destination = staging.joinpath(*PurePosixPath(relative).parts)
+ _atomic_write_jsonl(destination, cleaned_rows)
+
+ original = next(
+ (
+ record
+ for record in source_assets
+ if str(record["path"]) == relative
+ ),
+ None,
+ )
+ if original is None:
+ raise StagingError(f"annotation is not manifested: {relative}")
+ annotation_asset = dict(original)
+ annotation_asset["bytes"] = destination.stat().st_size
+ if "sha256" in annotation_asset:
+ annotation_asset["sha256"] = sha256_file(destination)
+ annotation_assets.append(annotation_asset)
+
+ def materialize(record: Mapping[str, Any]) -> str:
+ relative = str(record["path"])
+ source_path = _require_manifested_asset(
+ source,
+ record,
+ context="public source",
+ )
+ destination = staging.joinpath(*PurePosixPath(relative).parts)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if copy_mode == "hardlink":
+ try:
+ os.link(source_path, destination, follow_symlinks=True)
+ return "hardlinked"
+ except OSError:
+ pass
+ shutil.copyfile(source_path, destination, follow_symlinks=True)
+ return "copied"
+
+ executor = (
+ ThreadPoolExecutor(max_workers=workers)
+ if workers > 1
+ else None
+ )
+ try:
+ actions = (
+ executor.map(materialize, retained_assets)
+ if executor is not None
+ else map(materialize, retained_assets)
+ )
+ for action in actions:
+ counts[action] += 1
+ finally:
+ if executor is not None:
+ executor.shutdown(wait=True, cancel_futures=True)
+
+ public_assets = sorted(
+ [*annotation_assets, *retained_assets],
+ key=lambda item: str(item["path"]),
+ )
+ _atomic_write_jsonl(staging / MANIFEST_FILENAME, public_datasets)
+ _atomic_write_jsonl(staging / ASSET_MANIFEST_FILENAME, public_assets)
+ write_huggingface_metadata(
+ staging,
+ repo_id=DEFAULT_INDEX_REPO_ID,
+ )
+ summary = validate_staged_repository(
+ staging,
+ checksums=checksums,
+ repo_id=DEFAULT_INDEX_REPO_ID,
+ )
+ _publish_directory(staging, target, overwrite=overwrite)
+ except Exception:
+ if staging.exists() or staging.is_symlink():
+ _remove_path(staging)
+ raise
+
+ return {
+ **summary,
+ **counts,
+ "excluded_artifacts": len(excluded_artifacts),
+ "excluded_artifact_bytes": sum(
+ int(record["bytes"]) for record in excluded_artifacts
+ ),
+ }
+
+
+def export_evaluation_index(
+ repository_root: str | os.PathLike[str],
+ output_root: str | os.PathLike[str],
+ *,
+ overwrite: bool = False,
+ checksums: bool = False,
+) -> dict[str, Any]:
+ """Atomically export JSONL metadata while keeping large assets external."""
+
+ source = Path(repository_root).expanduser().resolve()
+ target = Path(output_root).expanduser().resolve(strict=False)
+ if source == target or source in target.parents or target in source.parents:
+ raise StagingError("evaluation index and complete repository must be separate")
+ if target.exists() or target.is_symlink():
+ if not overwrite:
+ raise FileExistsError(f"refusing to overwrite evaluation index: {target}")
+ if target.is_symlink():
+ raise StagingError(f"refusing to overwrite a symbolic-link target: {target}")
+
+ if checksums:
+ summary = validate_staged_repository(source, checksums=True)
+ else:
+ source_records = load_dataset_manifest(source)
+ source_assets = load_asset_manifest(source)
+ summary = {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "datasets": len(source_records),
+ "rows": sum(int(record["expected_count"]) for record in source_records),
+ "assets": len(source_assets),
+ "bytes": sum(int(asset["bytes"]) for asset in source_assets),
+ }
+ target.parent.mkdir(parents=True, exist_ok=True)
+ staging = Path(
+ tempfile.mkdtemp(
+ prefix=f".{target.name}.staging-",
+ dir=str(target.parent),
+ )
+ )
+ try:
+ shutil.copy2(source / MANIFEST_FILENAME, staging / MANIFEST_FILENAME)
+ shutil.copytree(
+ source / ANNOTATIONS_DIRECTORY,
+ staging / ANNOTATIONS_DIRECTORY,
+ )
+ records = load_dataset_manifest(staging / MANIFEST_FILENAME)
+ assets = load_asset_manifest(source / ASSET_MANIFEST_FILENAME)
+ annotation_assets = [
+ asset for asset in assets if str(asset["kind"]) == "annotations"
+ ]
+ if checksums:
+ validate_asset_manifest(
+ annotation_assets,
+ repository_root=staging,
+ context=str(staging / ASSET_MANIFEST_FILENAME),
+ )
+ else:
+ validate_asset_manifest(
+ annotation_assets,
+ context=str(staging / ASSET_MANIFEST_FILENAME),
+ )
+ for asset in annotation_assets:
+ _require_manifested_asset(
+ staging,
+ asset,
+ context="exported annotation",
+ )
+ declared_annotations = {
+ str(record["annotation_path"]) for record in records
+ }
+ manifested_annotations = {
+ str(asset["path"]) for asset in annotation_assets
+ }
+ if declared_annotations != manifested_annotations:
+ raise StagingError(
+ "exported annotation files do not match datasets.jsonl"
+ )
+ (staging / DATASET_CARD_FILENAME).write_text(
+ render_index_card(records, annotation_assets),
+ encoding="utf-8",
+ )
+ parent_stat = target.parent.stat()
+ for path in (staging, *staging.rglob("*")):
+ if os.geteuid() == 0:
+ os.chown(path, parent_stat.st_uid, parent_stat.st_gid)
+ path.chmod(0o755 if path.is_dir() else 0o644)
+ _publish_directory(staging, target, overwrite=overwrite)
+ except Exception:
+ if staging.exists() or staging.is_symlink():
+ _remove_path(staging)
+ raise
+
+ return {
+ **summary,
+ "output": str(target),
+ "annotation_assets": len(annotation_assets),
+ "external_assets": len(assets) - len(annotation_assets),
+ "asset_checksums": checksums,
+ }
+
+
+def asset_manifest_record_errors(record: Any) -> list[str]:
+ """Collect schema and canonical-layout errors for one asset record."""
+
+ if not isinstance(record, Mapping):
+ return ["record must be a JSON object"]
+ errors: list[str] = []
+ benchmark = record.get("benchmark")
+ try:
+ benchmark_id = validate_dataset_id(benchmark, context="benchmark")
+ except LayoutError as error:
+ errors.append(str(error))
+ benchmark_id = None
+
+ kind = record.get("kind")
+ if kind not in _ASSET_KINDS:
+ choices = ", ".join(sorted(_ASSET_KINDS))
+ errors.append(f"kind must be one of {{{choices}}}")
+ path = record.get("path")
+ try:
+ canonical_path = validate_repository_path(path, context="path")
+ except LayoutError as error:
+ errors.append(str(error))
+ canonical_path = None
+ if canonical_path is not None and benchmark_id is not None and kind in _ASSET_KINDS:
+ try:
+ if kind == "annotations":
+ expected = (
+ f"{PurePosixPath(annotation_path(benchmark_id, 'split')).parent}/"
+ )
+ if not canonical_path.startswith(expected):
+ raise LayoutError(f"path must be within {expected!r}")
+ elif kind == "artifacts":
+ validate_artifact_path(canonical_path, benchmark_id, context="path")
+ else:
+ validate_media_path(
+ canonical_path,
+ benchmark_id,
+ kind=str(kind),
+ context="path",
+ )
+ except LayoutError as error:
+ errors.append(str(error))
+
+ raw_digest = record.get("sha256")
+ digest = None
+ if raw_digest is not None:
+ try:
+ digest = validate_sha256(raw_digest)
+ except LayoutError as error:
+ errors.append(str(error))
+ if canonical_path is not None and digest is not None and kind != "annotations":
+ filename = PurePosixPath(canonical_path).name
+ if filename.split(".", 1)[0] != digest:
+ errors.append("content-addressed asset filename must begin with its sha256")
+ byte_count = record.get("bytes")
+ if isinstance(byte_count, bool) or not isinstance(byte_count, int) or byte_count < 0:
+ errors.append("bytes must be a nonnegative integer")
+ license_name = record.get("license")
+ if not isinstance(license_name, str) or not license_name.strip():
+ errors.append("license must be a nonempty string")
+ try:
+ json.dumps(record, ensure_ascii=False, allow_nan=False)
+ except (TypeError, ValueError) as error:
+ errors.append(f"record must contain only finite JSON values: {error}")
+ return errors
+
+
+def validate_asset_manifest_record(
+ record: Any,
+ *,
+ context: str = "asset record",
+) -> Mapping[str, Any]:
+ """Validate one ``assets.jsonl`` record."""
+
+ errors = asset_manifest_record_errors(record)
+ if errors:
+ raise StagingError(f"{context}: " + "; ".join(errors))
+ return record
+
+
+def validate_asset_manifest(
+ records: Iterable[Any],
+ *,
+ repository_root: str | os.PathLike[str] | None = None,
+ context: str = ASSET_MANIFEST_FILENAME,
+ checksums: bool = False,
+) -> list[Mapping[str, Any]]:
+ """Validate asset records, paths, sizes, and optionally file checksums."""
+
+ validated: list[Mapping[str, Any]] = []
+ seen: dict[str, str] = {}
+ previous_path: str | None = None
+ root = Path(repository_root).resolve() if repository_root is not None else None
+ for index, record in enumerate(records, start=1):
+ item = validate_asset_manifest_record(record, context=f"{context}:{index}")
+ path = str(item["path"])
+ folded = path.casefold()
+ prior = seen.get(folded)
+ if prior is not None:
+ collision = "duplicate" if prior == path else "case-colliding"
+ raise StagingError(f"{context}:{index}: {collision} asset path {path!r}")
+ if previous_path is not None and path < previous_path:
+ raise StagingError(f"{context}:{index}: asset records must be sorted by path")
+ seen[folded] = path
+ previous_path = path
+
+ if root is not None:
+ candidate = root.joinpath(*PurePosixPath(path).parts)
+ if candidate.is_symlink():
+ raise StagingError(f"{context}:{index}: staged asset must not be a symlink: {path}")
+ if not candidate.is_file():
+ raise StagingError(f"{context}:{index}: staged asset does not exist: {path}")
+ actual_bytes = candidate.stat().st_size
+ if actual_bytes != item["bytes"]:
+ raise StagingError(
+ f"{context}:{index}: byte-size mismatch for {path}: "
+ f"expected {item['bytes']}, got {actual_bytes}"
+ )
+ expected_digest = item.get("sha256")
+ if checksums and isinstance(expected_digest, str):
+ actual_digest = sha256_file(candidate)
+ if actual_digest != expected_digest:
+ raise StagingError(
+ f"{context}:{index}: checksum mismatch for {path}: "
+ f"expected {expected_digest}, got {actual_digest}"
+ )
+ validated.append(item)
+ return validated
+
+
+def load_asset_manifest(
+ path: str | os.PathLike[str],
+ *,
+ repository_root: str | os.PathLike[str] | None = None,
+ checksums: bool = False,
+) -> list[Mapping[str, Any]]:
+ """Load and validate ``assets.jsonl`` from a file or staged root."""
+
+ input_path = Path(path)
+ if input_path.is_dir():
+ input_path = input_path / ASSET_MANIFEST_FILENAME
+ if input_path.name != ASSET_MANIFEST_FILENAME:
+ raise StagingError(f"asset manifest must be named {ASSET_MANIFEST_FILENAME}")
+ records: list[Any] = []
+ try:
+ with input_path.open("r", encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, start=1):
+ if not line.strip():
+ continue
+ try:
+ records.append(json.loads(line))
+ except json.JSONDecodeError as error:
+ raise StagingError(
+ f"{input_path}:{line_number}: invalid JSON: {error}"
+ ) from error
+ except OSError as error:
+ raise StagingError(f"cannot read asset manifest {input_path}: {error}") from error
+ return validate_asset_manifest(
+ records,
+ repository_root=repository_root,
+ context=str(input_path),
+ checksums=checksums,
+ )
+
+
+def _staged_files(root: Path) -> set[str]:
+ files: set[str] = set()
+ for directory_name in _STAGED_DIRECTORIES:
+ directory = root / directory_name
+ if not directory.exists():
+ continue
+ if directory.is_symlink():
+ raise StagingError(f"staged directory must not be a symlink: {directory_name}")
+ for current, directories, filenames in os.walk(directory, followlinks=False):
+ current_path = Path(current)
+ for name in directories:
+ candidate = current_path / name
+ if candidate.is_symlink():
+ relative = candidate.relative_to(root).as_posix()
+ raise StagingError(f"staged directory must not be a symlink: {relative}")
+ for name in filenames:
+ candidate = current_path / name
+ relative = candidate.relative_to(root).as_posix()
+ if candidate.is_symlink():
+ raise StagingError(f"staged asset must not be a symlink: {relative}")
+ if not candidate.is_file():
+ raise StagingError(f"staged asset must be a regular file: {relative}")
+ files.add(relative)
+ return files
+
+
+def _manifest_references(
+ root: Path,
+ manifest: Sequence[Mapping[str, Any]],
+ datasets: Mapping[tuple[str, str], Sequence[Mapping[str, Any]]],
+) -> set[str]:
+ references: set[str] = set()
+ for record in manifest:
+ key = (str(record["benchmark"]), str(record["split"]))
+ references.add(str(record["annotation_path"]))
+ for field in ("evaluation", "preprocessing", "metadata"):
+ value = record.get(field)
+ if isinstance(value, Mapping):
+ references.update(
+ path for _context, path in declared_repository_paths(value, context=field)
+ )
+ legacy = record.get("legacy_environment")
+ if isinstance(legacy, Mapping):
+ for name, value in legacy.items():
+ if _is_path_key(name) and isinstance(value, str):
+ references.add(value)
+ for row in datasets.get(key, ()):
+ references.update(evaluation_asset_paths(row))
+
+ return {path for path in references if root.joinpath(*PurePosixPath(path).parts).is_file()}
+
+
+def validate_staged_repository(
+ repository_root: str | os.PathLike[str],
+ *,
+ require_redistribution_authorized: bool = True,
+ checksums: bool = False,
+ repo_id: str = DEFAULT_DATASET_REPO_ID,
+) -> dict[str, Any]:
+ """Validate contracts, sizes, symlink safety, references, and optional checksums."""
+
+ raw_root = Path(repository_root).expanduser()
+ if raw_root.is_symlink():
+ raise StagingError("evaluation repository root must not be a symlink")
+ root = raw_root.resolve()
+ if not root.is_dir():
+ raise StagingError(f"evaluation repository does not exist: {root}")
+ for manifest_name in (
+ MANIFEST_FILENAME,
+ ASSET_MANIFEST_FILENAME,
+ DATASET_CARD_FILENAME,
+ GIT_ATTRIBUTES_FILENAME,
+ ):
+ manifest_path = root / manifest_name
+ if manifest_path.is_symlink() or not manifest_path.is_file():
+ raise StagingError(f"required staged file is missing or a symlink: {manifest_name}")
+ try:
+ datasets = validate_evaluation_repository(
+ root,
+ require_redistribution_authorized=require_redistribution_authorized,
+ )
+ manifest = load_dataset_manifest(root)
+ except ManifestError as error:
+ raise StagingError(str(error)) from error
+ assets = load_asset_manifest(
+ root,
+ repository_root=root,
+ checksums=checksums,
+ )
+ try:
+ validate_huggingface_metadata(root, repo_id=repo_id)
+ except DatasetCardError as error:
+ raise StagingError(str(error)) from error
+
+ staged_files = _staged_files(root)
+ manifested_files = {str(asset["path"]) for asset in assets}
+ if staged_files != manifested_files:
+ unmanifested = sorted(staged_files - manifested_files)
+ missing = sorted(manifested_files - staged_files)
+ details = []
+ if unmanifested:
+ details.append("unmanifested: " + ", ".join(unmanifested[:5]))
+ if missing:
+ details.append("missing: " + ", ".join(missing[:5]))
+ raise StagingError("unreferenced or unmanifested staged assets; " + "; ".join(details))
+
+ references = _manifest_references(root, manifest, datasets)
+ unreferenced = sorted(manifested_files - references)
+ if unreferenced:
+ raise StagingError("unreferenced staged assets: " + ", ".join(unreferenced[:10]))
+ return {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "datasets": len(manifest),
+ "rows": sum(len(rows) for rows in datasets.values()),
+ "assets": len(assets),
+ "bytes": sum(int(asset["bytes"]) for asset in assets),
+ }
+
+
+inventory_evaluation_data = inventory_evaluation_sources
+inventory_evaluation_repository = inventory_evaluation_sources
+inventory_repository = inventory_evaluation_sources
+build_evaluation_data = build_evaluation_repository
+build_repository = build_evaluation_repository
+load_assets_manifest = load_asset_manifest
+validate_assets_manifest = validate_asset_manifest
+validate_evaluation_data_repository = validate_staged_repository
+validate_repository = validate_staged_repository
+
+
+__all__ = [
+ "AssetManifestRecord",
+ "StagingError",
+ "asset_manifest_record_errors",
+ "build_evaluation_data",
+ "build_evaluation_repository",
+ "build_repository",
+ "export_evaluation_index",
+ "inventory_evaluation_data",
+ "inventory_and_write_locked_manifest",
+ "inventory_evaluation_repository",
+ "inventory_evaluation_sources",
+ "inventory_repository",
+ "load_asset_manifest",
+ "load_assets_manifest",
+ "merge_evaluation_repository",
+ "validate_asset_manifest",
+ "validate_asset_manifest_record",
+ "validate_assets_manifest",
+ "validate_evaluation_data_repository",
+ "validate_repository",
+ "validate_staged_repository",
+ "write_locked_source_manifest",
+]
diff --git a/code/orarl/resources.py b/code/orarl/resources.py
new file mode 100644
index 0000000000000000000000000000000000000000..f5f6887ae158d4eb78c6b03ca529411b926b25cd
--- /dev/null
+++ b/code/orarl/resources.py
@@ -0,0 +1,83 @@
+"""Locate OraRL configuration, documentation, and launcher resources."""
+
+from __future__ import annotations
+
+from importlib.metadata import PackageNotFoundError, distribution
+from pathlib import Path, PurePosixPath
+
+_RESOURCE_KINDS = frozenset({"configs", "docs", "scripts"})
+
+
+def _safe_name(name: str) -> str:
+ candidate = str(name).strip()
+ if not candidate or candidate in {".", ".."} or Path(candidate).name != candidate:
+ raise ValueError(f"resource name must be a file name, got {name!r}")
+ return candidate
+
+
+def _source_resource(kind: str, name: str) -> Path | None:
+ candidate = Path(__file__).resolve().parent.parent / kind / name
+ return candidate if candidate.is_file() else None
+
+
+def _installed_resource(kind: str, name: str) -> Path | None:
+ try:
+ package_distribution = distribution("orarl")
+ except PackageNotFoundError:
+ return None
+
+ suffix = ("share", "orarl", kind, name)
+ for entry in package_distribution.files or ():
+ parts = PurePosixPath(str(entry)).parts
+ if len(parts) >= len(suffix) and tuple(parts[-len(suffix) :]) == suffix:
+ candidate = Path(package_distribution.locate_file(entry)).resolve()
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def resource_path(kind: str, name: str) -> Path:
+ """Return one packaged resource from a source or wheel installation."""
+
+ normalized_kind = str(kind).strip().casefold()
+ if normalized_kind not in _RESOURCE_KINDS:
+ choices = ", ".join(sorted(_RESOURCE_KINDS))
+ raise ValueError(f"resource kind must be one of {{{choices}}}, got {kind!r}")
+ normalized_name = _safe_name(name)
+ candidate = _source_resource(normalized_kind, normalized_name)
+ if candidate is None:
+ candidate = _installed_resource(normalized_kind, normalized_name)
+ if candidate is None:
+ raise FileNotFoundError(
+ f"OraRL {normalized_kind} resource was not found: {normalized_name}"
+ )
+ return candidate
+
+
+def config_path(name: str) -> Path:
+ """Return an included public YAML configuration."""
+
+ return resource_path("configs", name)
+
+
+def documentation_path(name: str) -> Path:
+ """Return an included release document."""
+
+ return resource_path("docs", name)
+
+
+def script_path(name: str) -> Path:
+ """Return an included shell or Python launcher."""
+
+ return resource_path("scripts", name)
+
+
+def available_configs() -> tuple[str, ...]:
+ """Return the stable names of the four public training recipes."""
+
+ return (
+ "grpo_4b.yaml",
+ "grpo_9b.yaml",
+ "orarl_4b.yaml",
+ "orarl_9b.yaml",
+ )
diff --git a/code/orarl/rewards/__init__.py b/code/orarl/rewards/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2e87f97641d1ac8305d40bcfa1f5fb042f6ce37f
--- /dev/null
+++ b/code/orarl/rewards/__init__.py
@@ -0,0 +1,49 @@
+"""Unified rewards and oracle builders for OraRL."""
+
+from .router import (
+ DEFAULT_MODULE_PATHS,
+ REWARD_NAME,
+ REWARD_TYPE,
+ RewardRouter,
+ RouterSettings,
+ build_oracle_response,
+ build_oracle_response_from_ground_truth,
+ compute_reward,
+ compute_score,
+ infer_task_from_ground_truth,
+ normalize_task_name,
+)
+from .types import (
+ RewardAdapter,
+ RewardBatch,
+ RewardContractError,
+ RewardError,
+ RewardMetrics,
+ RewardResult,
+ RewardSample,
+ TaskFamily,
+ UnknownTaskError,
+)
+
+__all__ = [
+ "DEFAULT_MODULE_PATHS",
+ "REWARD_NAME",
+ "REWARD_TYPE",
+ "RewardAdapter",
+ "RewardBatch",
+ "RewardContractError",
+ "RewardError",
+ "RewardMetrics",
+ "RewardResult",
+ "RewardRouter",
+ "RewardSample",
+ "RouterSettings",
+ "TaskFamily",
+ "UnknownTaskError",
+ "build_oracle_response",
+ "build_oracle_response_from_ground_truth",
+ "compute_reward",
+ "compute_score",
+ "infer_task_from_ground_truth",
+ "normalize_task_name",
+]
diff --git a/code/orarl/rewards/adapters/__init__.py b/code/orarl/rewards/adapters/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c2db3e81ad1dfee1ba632c4b9977a7a056f47e75
--- /dev/null
+++ b/code/orarl/rewards/adapters/__init__.py
@@ -0,0 +1,15 @@
+"""Dependency-light reward adapters shipped with OraRL.
+
+Modules are intentionally not imported here so the reward router can load each
+task family lazily.
+"""
+
+__all__ = [
+ "segmentation",
+ "spatial_grounding",
+ "spatial_intelligence",
+ "spatial_temporal_grounding",
+ "temporal_grounding",
+ "tracking",
+ "video_qa",
+]
diff --git a/code/orarl/rewards/adapters/_common.py b/code/orarl/rewards/adapters/_common.py
new file mode 100644
index 0000000000000000000000000000000000000000..4fffbef101990b3cfc137d643daf4c8fe6789f43
--- /dev/null
+++ b/code/orarl/rewards/adapters/_common.py
@@ -0,0 +1,193 @@
+"""Small parsing and geometry primitives shared by built-in adapters."""
+
+from __future__ import annotations
+
+import json
+import math
+import re
+from collections.abc import Mapping, Sequence
+from typing import Any, Optional
+
+_ANSWER_RE = re.compile(
+ r"\s*(.*?)\s* ",
+ flags=re.DOTALL | re.IGNORECASE,
+)
+_CANONICAL_ANSWER_RE = re.compile(
+ r"\A\s*(?:(?:)?.*? \s*)?"
+ r"\s*(.*?)\s* \s*\Z",
+ flags=re.DOTALL | re.IGNORECASE,
+)
+_FENCE_RE = re.compile(
+ r"\A\s*```(?:json)?\s*(.*?)\s*```\s*\Z",
+ flags=re.DOTALL | re.IGNORECASE,
+)
+_THINK_BLOCK_RE = re.compile(
+ r".*? ",
+ flags=re.DOTALL | re.IGNORECASE,
+)
+
+
+def finite_float(value: Any) -> Optional[float]:
+ """Return a finite float, rejecting booleans and invalid values."""
+
+ if isinstance(value, bool):
+ return None
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ return None
+ return number if math.isfinite(number) else None
+
+
+def exact_answer_payload(value: Any) -> Optional[str]:
+ """Extract a payload only from the canonical final-answer shape."""
+
+ match = _CANONICAL_ANSWER_RE.fullmatch(str(value or ""))
+ if match is None or not match.group(1).strip():
+ return None
+ return match.group(1).strip()
+
+
+def answer_payload(value: Any) -> str:
+ """Extract the final answer block when present, otherwise return text."""
+
+ text = str(value or "").strip()
+ matches = _ANSWER_RE.findall(text)
+ return matches[-1].strip() if matches else text
+
+
+def final_response_text(value: Any) -> str:
+ """Remove a complete reasoning block and an optional answer wrapper."""
+
+ text = str(value or "").strip()
+ text = _THINK_BLOCK_RE.sub("", text).strip()
+ if " " in text.lower():
+ text = re.split(r" ", text, flags=re.IGNORECASE)[-1].strip()
+ matches = _ANSWER_RE.findall(text)
+ return matches[-1].strip() if matches else text
+
+
+def unfence(value: Any) -> str:
+ text = str(value or "").strip()
+ match = _FENCE_RE.fullmatch(text)
+ return match.group(1).strip() if match is not None else text
+
+
+def parse_json(value: Any) -> Any:
+ """Parse JSON from common answer/fence wrappers without executing code."""
+
+ if isinstance(value, (Mapping, list, tuple)):
+ return value
+ text = unfence(answer_payload(value))
+ try:
+ return json.loads(text)
+ except (TypeError, ValueError):
+ pass
+
+ decoder = json.JSONDecoder()
+ for index, character in enumerate(text):
+ if character not in "[{":
+ continue
+ try:
+ parsed, _ = decoder.raw_decode(text[index:])
+ except (TypeError, ValueError):
+ continue
+ return parsed
+ return None
+
+
+def parse_mapping(value: Any) -> Optional[dict[str, Any]]:
+ payload = parse_json(value)
+ return dict(payload) if isinstance(payload, Mapping) else None
+
+
+def canonical_json(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+
+
+def canonical_answer(value: Any) -> str:
+ payload = value if isinstance(value, str) else canonical_json(value)
+ return f"{payload.strip()} "
+
+
+def normalize_box(value: Any, *, reorder: bool = False) -> Optional[list[float]]:
+ """Normalize one ``[x1, y1, x2, y2]`` box."""
+
+ if isinstance(value, Mapping):
+ for key in ("bbox_2d", "bbox", "box", "boxes"):
+ if key in value:
+ box = normalize_box(value[key], reorder=reorder)
+ if box is not None:
+ return box
+ return None
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)) and len(value) == 4:
+ box = [finite_float(coordinate) for coordinate in value]
+ if all(coordinate is not None for coordinate in box):
+ normalized = [float(coordinate) for coordinate in box]
+ if reorder:
+ normalized[0], normalized[2] = sorted((normalized[0], normalized[2]))
+ normalized[1], normalized[3] = sorted((normalized[1], normalized[3]))
+ return normalized
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
+ for item in value:
+ box = normalize_box(item, reorder=reorder)
+ if box is not None:
+ return box
+ return None
+
+
+def normalize_boxes(value: Any) -> dict[str, list[float]]:
+ """Normalize frame-keyed boxes using the released integer-key contract."""
+
+ if not isinstance(value, Mapping):
+ return {}
+ boxes: dict[str, list[float]] = {}
+ for key, raw_box in value.items():
+ key_number = finite_float(key)
+ box = normalize_box(raw_box)
+ if key_number is None or box is None or not key_number.is_integer():
+ continue
+ boxes[str(int(key_number))] = box
+ return boxes
+
+
+def box_iou(first: Any, second: Any) -> float:
+ box_a = normalize_box(first)
+ box_b = normalize_box(second)
+ if box_a is None or box_b is None:
+ return 0.0
+ x1 = max(box_a[0], box_b[0])
+ y1 = max(box_a[1], box_b[1])
+ x2 = min(box_a[2], box_b[2])
+ y2 = min(box_a[3], box_b[3])
+ intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
+ area_a = max(0.0, box_a[2] - box_a[0]) * max(0.0, box_a[3] - box_a[1])
+ area_b = max(0.0, box_b[2] - box_b[0]) * max(0.0, box_b[3] - box_b[1])
+ union = area_a + area_b - intersection
+ return intersection / union if union > 0.0 else 0.0
+
+
+def interval_iou(first: Any, second: Any) -> float:
+ if not (
+ isinstance(first, Sequence)
+ and not isinstance(first, (str, bytes))
+ and len(first) == 2
+ and isinstance(second, Sequence)
+ and not isinstance(second, (str, bytes))
+ and len(second) == 2
+ ):
+ return 0.0
+ first_values = [finite_float(value) for value in first]
+ second_values = [finite_float(value) for value in second]
+ if any(value is None for value in first_values + second_values):
+ return 0.0
+ a0, a1 = sorted(float(value) for value in first_values)
+ b0, b1 = sorted(float(value) for value in second_values)
+ intersection = max(0.0, min(a1, b1) - max(a0, b0))
+ union = max(a1, b1) - min(a0, b0)
+ return intersection / union if union > 0.0 else 0.0
+
+
+def ground_truth(item: Mapping[str, Any]) -> Any:
+ value = item.get("ground_truth")
+ return item.get("answer") if value is None else value
diff --git a/code/orarl/rewards/adapters/segmentation.py b/code/orarl/rewards/adapters/segmentation.py
new file mode 100644
index 0000000000000000000000000000000000000000..f374c29df76932875b1fcf8c95431023b9ecb08a
--- /dev/null
+++ b/code/orarl/rewards/adapters/segmentation.py
@@ -0,0 +1,629 @@
+"""Built-in mask-aware segmentation reward with a deterministic proxy fallback."""
+
+from __future__ import annotations
+
+import ast
+import itertools
+import json
+import math
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+import numpy as np
+
+from ..types import RewardContractError
+from ._common import (
+ box_iou,
+ canonical_answer,
+ exact_answer_payload,
+ finite_float,
+ ground_truth,
+ normalize_box,
+ parse_mapping,
+)
+
+REWARD_NAME = "segmentation"
+REWARD_TYPE = "batch"
+
+POINT_SIGMA = 50.0
+TIME_TAU = 2.0
+IMAGE_WEIGHTS = (0.50, 0.25, 0.25)
+VIDEO_WEIGHTS = (0.35, 0.10, 0.40, 0.15)
+REQUIRE_VIDEO_TIME = True
+MASK_AWARE = True
+MASK_POSITIVE_ZERO_CAP = 0.10
+MASK_BOX_MISS_CAP = 0.20
+MASK_BOX_MISS_IOU = 0.10
+MASK_POINT_RADIUS = 3
+
+
+def _mapping(value: Any) -> dict[str, Any] | None:
+ mapping = parse_mapping(value)
+ if mapping is not None or not isinstance(value, str):
+ return mapping
+ try:
+ parsed = ast.literal_eval(value)
+ except (SyntaxError, ValueError):
+ return None
+ return dict(parsed) if isinstance(parsed, Mapping) else None
+
+
+def _box(value: Any) -> list[float] | None:
+ return normalize_box(value, reorder=True)
+
+
+def _points(value: Any) -> list[list[float]] | None:
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)) or len(value) != 3:
+ return None
+ points: list[list[float]] = []
+ for point in value:
+ if not isinstance(point, Sequence) or isinstance(point, (str, bytes)) or len(point) < 2:
+ return None
+ x = finite_float(point[0])
+ y = finite_float(point[1])
+ if x is None or y is None:
+ return None
+ points.append([x, y])
+ return points
+
+
+def assignment_similarity(
+ prediction: list[list[float]] | None,
+ target: list[list[float]] | None,
+ *,
+ sigma: float,
+) -> float:
+ """Optimal three-point assignment with Gaussian distance similarity."""
+
+ if prediction is None or target is None or sigma <= 0.0:
+ return 0.0
+ best_distance = math.inf
+ for permutation in itertools.permutations(range(3)):
+ distance = sum(
+ math.hypot(
+ prediction[permutation[index]][0] - target[index][0],
+ prediction[permutation[index]][1] - target[index][1],
+ )
+ for index in range(3)
+ )
+ best_distance = min(best_distance, distance)
+ average_distance = best_distance / 3.0
+ return math.exp(-(average_distance**2) / (2.0 * sigma**2))
+
+
+def _decode_compressed_counts(value: str) -> list[int]:
+ counts: list[int] = []
+ position = 0
+ while position < len(value):
+ decoded = 0
+ shift = 0
+ more = True
+ while more:
+ if position >= len(value):
+ raise ValueError("Truncated compressed RLE.")
+ code = ord(value[position]) - 48
+ position += 1
+ decoded |= (code & 0x1F) << (5 * shift)
+ more = bool(code & 0x20)
+ if not more and code & 0x10:
+ decoded |= -1 << (5 * (shift + 1))
+ shift += 1
+ if len(counts) > 2:
+ decoded += counts[-2]
+ counts.append(decoded)
+ return counts
+
+
+def decode_coco_rle(value: Any) -> np.ndarray | None:
+ """Decode compressed or uncompressed COCO RLE without pycocotools."""
+
+ if not isinstance(value, Mapping):
+ return None
+ size = value.get("size")
+ if not isinstance(size, Sequence) or isinstance(size, (str, bytes)) or len(size) != 2:
+ return None
+ try:
+ height, width = int(size[0]), int(size[1])
+ except (TypeError, ValueError):
+ return None
+ if height <= 0 or width <= 0:
+ return None
+
+ raw_counts = value.get("counts")
+ try:
+ if isinstance(raw_counts, bytes):
+ raw_counts = raw_counts.decode("ascii")
+ if isinstance(raw_counts, str):
+ counts = _decode_compressed_counts(raw_counts)
+ elif isinstance(raw_counts, Sequence) and not isinstance(raw_counts, (str, bytes)):
+ counts = [int(run) for run in raw_counts]
+ else:
+ return None
+ except (TypeError, ValueError):
+ return None
+ if any(run < 0 for run in counts):
+ return None
+
+ flat = np.zeros(height * width, dtype=np.uint8)
+ offset = 0
+ foreground = False
+ for run in counts:
+ end = min(flat.size, offset + run)
+ if foreground:
+ flat[offset:end] = 1
+ offset = end
+ foreground = not foreground
+ if offset >= flat.size:
+ break
+ return flat.reshape((width, height)).T.astype(bool)
+
+
+def _segmentation_output(item: Mapping[str, Any]) -> dict[str, Any] | None:
+ return _mapping(item.get("segmentation_output"))
+
+
+def _rle_value(container: Mapping[Any, Any], key: Any) -> Any:
+ if key in container:
+ return container[key]
+ text_key = str(key)
+ if text_key in container:
+ return container[text_key]
+ for candidate, value in container.items():
+ if str(candidate) == text_key:
+ return value
+ return None
+
+
+def _metadata_number(
+ item: Mapping[str, Any],
+ segmentation_output: Mapping[str, Any],
+ *names: str,
+) -> float | None:
+ sources: list[Any] = [item, segmentation_output]
+ for key in ("metadata", "video_metadata", "image_metadata"):
+ sources.extend(
+ source.get(key) for source in (item, segmentation_output) if isinstance(source, Mapping)
+ )
+ for media_key in ("videos", "images"):
+ media = item.get(media_key)
+ if isinstance(media, list) and media:
+ sources.append(media[0])
+ for source in sources:
+ if not isinstance(source, Mapping):
+ continue
+ for name in names:
+ number = finite_float(source.get(name))
+ if number is not None:
+ return number
+ return None
+
+
+def _coordinate_size(
+ item: Mapping[str, Any],
+ mask: np.ndarray,
+) -> tuple[int, int]:
+ output = _segmentation_output(item) or {}
+ sources: list[Any] = [item, output]
+ for source in (item, output):
+ if not isinstance(source, Mapping):
+ continue
+ sources.append(_mapping(source.get("resolution")))
+ for key in ("metadata", "video_metadata", "image_metadata"):
+ sources.append(source.get(key))
+ for media_key in ("videos", "images"):
+ media = item.get(media_key)
+ if isinstance(media, list) and media:
+ sources.append(media[0])
+ for source in sources:
+ if not isinstance(source, Mapping):
+ continue
+ width = finite_float(source.get("width") or source.get("w"))
+ height = finite_float(source.get("height") or source.get("h"))
+ if width is not None and height is not None and width > 0 and height > 0:
+ return int(round(width)), int(round(height))
+ return int(mask.shape[1]), int(mask.shape[0])
+
+
+def _image_mask(item: Mapping[str, Any]) -> np.ndarray | None:
+ output = _segmentation_output(item)
+ if output is None:
+ return None
+ if "counts" in output and "size" in output:
+ return decode_coco_rle(output)
+ for key in ("segmentation_rle", "rle", "mask", "masks"):
+ candidate = output.get(key)
+ if isinstance(candidate, Mapping) and {
+ "counts",
+ "size",
+ }.issubset(candidate):
+ return decode_coco_rle(candidate)
+ if isinstance(candidate, Mapping) and candidate:
+ first = next(iter(candidate.values()))
+ if isinstance(first, Mapping):
+ return decode_coco_rle(first)
+ return None
+
+
+def _video_mask(
+ item: Mapping[str, Any],
+ predicted_time: float,
+) -> tuple[np.ndarray | None, int, int] | None:
+ output = _segmentation_output(item)
+ if output is None:
+ return None
+ rles = output.get("segmentation_rle") or output.get("rle") or output.get("masks")
+ if not isinstance(rles, Mapping) or not rles:
+ return None
+ frames = output.get("frames")
+ frame_keys = list(frames) if isinstance(frames, list) and frames else list(rles)
+ if not frame_keys:
+ return None
+
+ fps = _metadata_number(item, output, "fps", "video_fps")
+ numeric_keys = [finite_float(key) for key in frame_keys]
+ if fps is not None and fps > 0.0:
+ target_frame = predicted_time * fps
+ if all(key is not None for key in numeric_keys):
+ frame_index = min(
+ range(len(frame_keys)),
+ key=lambda index: abs(float(numeric_keys[index]) - target_frame),
+ )
+ else:
+ frame_index = int(round(target_frame))
+ else:
+ duration = _metadata_number(item, output, "video_second", "duration", "duration_seconds")
+ if duration is not None and duration > 0.0:
+ frame_index = int(round(predicted_time / duration * max(len(frame_keys) - 1, 0)))
+ elif all(key is not None for key in numeric_keys):
+ frame_index = min(
+ range(len(frame_keys)),
+ key=lambda index: abs(float(numeric_keys[index]) - predicted_time),
+ )
+ else:
+ frame_index = 0
+ frame_index = max(0, min(frame_index, len(frame_keys) - 1))
+ rle = _rle_value(rles, frame_keys[frame_index])
+ return decode_coco_rle(rle), frame_index, len(frame_keys)
+
+
+def _mask_box(
+ mask: np.ndarray,
+ coordinate_width: int,
+ coordinate_height: int,
+) -> list[float] | None:
+ y_values, x_values = np.where(mask)
+ if not len(x_values):
+ return None
+ mask_height, mask_width = mask.shape
+ return [
+ float(x_values.min()) * coordinate_width / mask_width,
+ float(y_values.min()) * coordinate_height / mask_height,
+ float(x_values.max() + 1) * coordinate_width / mask_width,
+ float(y_values.max() + 1) * coordinate_height / mask_height,
+ ]
+
+
+def _denormalize_box(
+ box: list[float] | None,
+ coordinate_width: int,
+ coordinate_height: int,
+) -> list[float] | None:
+ if box is None:
+ return None
+ return [
+ box[0] * coordinate_width / 1000.0,
+ box[1] * coordinate_height / 1000.0,
+ box[2] * coordinate_width / 1000.0,
+ box[3] * coordinate_height / 1000.0,
+ ]
+
+
+def _point_in_mask(
+ mask: np.ndarray,
+ point: Sequence[float],
+ *,
+ coordinate_width: int,
+ coordinate_height: int,
+ radius: int,
+) -> bool:
+ mask_height, mask_width = mask.shape
+ coordinate_x = point[0] * coordinate_width / 1000.0
+ coordinate_y = point[1] * coordinate_height / 1000.0
+ x = int(round(coordinate_x * mask_width / coordinate_width))
+ y = int(round(coordinate_y * mask_height / coordinate_height))
+ if x < 0 or y < 0 or x >= mask_width or y >= mask_height:
+ return False
+ if mask[y, x]:
+ return True
+ if radius <= 0:
+ return False
+ return bool(
+ np.any(
+ mask[
+ max(0, y - radius) : min(mask_height, y + radius + 1),
+ max(0, x - radius) : min(mask_width, x + radius + 1),
+ ]
+ )
+ )
+
+
+def _point_ratio(
+ mask: np.ndarray,
+ points: list[list[float]] | None,
+ *,
+ inside: bool,
+ coordinate_width: int,
+ coordinate_height: int,
+ radius: int,
+) -> float:
+ if points is None:
+ return 0.0
+ matches: list[bool] = []
+ for point in points:
+ point_is_inside = _point_in_mask(
+ mask,
+ point,
+ coordinate_width=coordinate_width,
+ coordinate_height=coordinate_height,
+ radius=radius,
+ )
+ matches.append(point_is_inside if inside else not point_is_inside)
+ return sum(matches) / len(matches)
+
+
+def _weights(
+ kwargs: Mapping[str, Any],
+ prefix: str,
+ defaults: tuple[float, ...],
+) -> tuple[float, ...]:
+ names = (
+ ("box_weight", "positive_weight", "negative_weight")
+ if prefix == "image"
+ else ("box_weight", "time_weight", "positive_weight", "negative_weight")
+ )
+ values: list[float] = []
+ for name, default in zip(names, defaults):
+ value = finite_float(kwargs.get(f"{prefix}_{name}", default))
+ if value is None or value < 0.0:
+ raise ValueError(f"{prefix}_{name} must be a non-negative number.")
+ values.append(value)
+ return tuple(values)
+
+
+def _modality(item: Mapping[str, Any], target: Mapping[str, Any] | None) -> str:
+ data_type = str(item.get("data_type") or "").strip().lower()
+ if data_type in {"image", "video"}:
+ return data_type
+ has_time = target is not None and finite_float(target.get("time")) is not None
+ return "video" if has_time else "image"
+
+
+def _mask_components(
+ item: Mapping[str, Any],
+ mask: np.ndarray,
+ prediction: Mapping[str, Any],
+ predicted_box: list[float] | None,
+ *,
+ weights: tuple[float, ...],
+ video: bool,
+ radius: int,
+ positive_zero_cap: float,
+ box_miss_cap: float,
+ box_miss_iou: float,
+) -> dict[str, float]:
+ if not np.any(mask):
+ return {
+ "accuracy": 0.0,
+ "mask_box_iou": 0.0,
+ "mask_pos_inside": 0.0,
+ "mask_neg_outside": 0.0,
+ }
+ coordinate_width, coordinate_height = _coordinate_size(item, mask)
+ mask_box_iou = box_iou(
+ _denormalize_box(
+ predicted_box,
+ coordinate_width,
+ coordinate_height,
+ ),
+ _mask_box(mask, coordinate_width, coordinate_height),
+ )
+ positive_inside = _point_ratio(
+ mask,
+ _points(prediction.get("positive_points")),
+ inside=True,
+ coordinate_width=coordinate_width,
+ coordinate_height=coordinate_height,
+ radius=radius,
+ )
+ negative_outside = _point_ratio(
+ mask,
+ _points(prediction.get("negative_points")),
+ inside=False,
+ coordinate_width=coordinate_width,
+ coordinate_height=coordinate_height,
+ radius=radius,
+ )
+ if video:
+ box_weight, time_weight, positive_weight, negative_weight = weights
+ accuracy = (
+ box_weight * mask_box_iou
+ + time_weight
+ + positive_weight * positive_inside
+ + negative_weight * negative_outside
+ )
+ else:
+ box_weight, positive_weight, negative_weight = weights
+ accuracy = (
+ box_weight * mask_box_iou
+ + positive_weight * positive_inside
+ + negative_weight * negative_outside
+ )
+ if positive_inside <= 0.0:
+ accuracy = min(accuracy, positive_zero_cap)
+ if mask_box_iou < box_miss_iou:
+ accuracy = min(accuracy, box_miss_cap)
+ return {
+ "accuracy": max(0.0, min(1.0, accuracy)),
+ "mask_box_iou": float(mask_box_iou),
+ "mask_pos_inside": float(positive_inside),
+ "mask_neg_outside": float(negative_outside),
+ }
+
+
+def compute_score(
+ batch: list[dict[str, Any]],
+ **kwargs: Any,
+) -> list[dict[str, float]]:
+ sigma = finite_float(kwargs.get("point_sigma", POINT_SIGMA))
+ time_tau = finite_float(kwargs.get("time_tau", TIME_TAU))
+ if sigma is None or sigma <= 0.0:
+ raise ValueError("point_sigma must be positive.")
+ if time_tau is None or time_tau <= 0.0:
+ raise ValueError("time_tau must be positive.")
+ image_weights = _weights(kwargs, "image", IMAGE_WEIGHTS)
+ video_weights = _weights(kwargs, "video", VIDEO_WEIGHTS)
+ mask_aware = bool(kwargs.get("mask_aware", MASK_AWARE))
+ require_video_time = bool(kwargs.get("require_video_time", REQUIRE_VIDEO_TIME))
+ radius = int(kwargs.get("mask_point_radius", MASK_POINT_RADIUS))
+ positive_zero_cap = float(kwargs.get("mask_positive_zero_cap", MASK_POSITIVE_ZERO_CAP))
+ box_miss_cap = float(kwargs.get("mask_box_miss_cap", MASK_BOX_MISS_CAP))
+ box_miss_iou = float(kwargs.get("mask_box_miss_iou", MASK_BOX_MISS_IOU))
+
+ results: list[dict[str, float]] = []
+ for item in batch:
+ target = _mapping(ground_truth(item))
+ response_payload = exact_answer_payload(item.get("response"))
+ prediction = _mapping(response_payload)
+ try:
+ strict_prediction = json.loads(response_payload or "")
+ except (TypeError, ValueError):
+ strict_prediction = None
+ predicted_box = _box(prediction.get("boxes")) if prediction is not None else None
+ target_box = _box(target.get("boxes")) if target is not None else None
+ predicted_positive = (
+ _points(prediction.get("positive_points")) if prediction is not None else None
+ )
+ target_positive = _points(target.get("positive_points")) if target is not None else None
+ predicted_negative = (
+ _points(prediction.get("negative_points")) if prediction is not None else None
+ )
+ target_negative = _points(target.get("negative_points")) if target is not None else None
+ modality = _modality(item, target)
+ predicted_time = finite_float(prediction.get("time")) if prediction is not None else None
+ target_time = finite_float(target.get("time")) if target is not None else None
+
+ valid_structure = (
+ prediction is not None
+ and isinstance(strict_prediction, Mapping)
+ and predicted_box is not None
+ and predicted_positive is not None
+ and predicted_negative is not None
+ and (modality != "video" or not require_video_time or predicted_time is not None)
+ )
+ format_score = float(response_payload is not None and valid_structure)
+
+ proxy_box_iou = box_iou(predicted_box, target_box)
+ positive_similarity = assignment_similarity(
+ predicted_positive, target_positive, sigma=sigma
+ )
+ negative_similarity = assignment_similarity(
+ predicted_negative, target_negative, sigma=sigma
+ )
+ time_similarity = 0.0
+ if predicted_time is not None and target_time is not None:
+ time_similarity = math.exp(-abs(predicted_time - target_time) / time_tau)
+
+ mask_score: dict[str, float] | None = None
+ if mask_aware and prediction is not None:
+ if modality == "video" and predicted_time is not None:
+ selected = _video_mask(item, predicted_time)
+ if selected is not None:
+ mask, _, _ = selected
+ if mask is None:
+ mask_score = {
+ "accuracy": 0.0,
+ "mask_box_iou": 0.0,
+ "mask_pos_inside": 0.0,
+ "mask_neg_outside": 0.0,
+ }
+ else:
+ mask_score = _mask_components(
+ item,
+ mask,
+ prediction,
+ predicted_box,
+ weights=video_weights,
+ video=True,
+ radius=radius,
+ positive_zero_cap=positive_zero_cap,
+ box_miss_cap=box_miss_cap,
+ box_miss_iou=box_miss_iou,
+ )
+ elif modality == "image":
+ mask = _image_mask(item)
+ if mask is not None:
+ mask_score = _mask_components(
+ item,
+ mask,
+ prediction,
+ predicted_box,
+ weights=image_weights,
+ video=False,
+ radius=radius,
+ positive_zero_cap=positive_zero_cap,
+ box_miss_cap=box_miss_cap,
+ box_miss_iou=box_miss_iou,
+ )
+
+ if mask_score is not None:
+ accuracy = mask_score["accuracy"]
+ elif modality == "video":
+ box_weight, time_weight, positive_weight, negative_weight = video_weights
+ accuracy = (
+ box_weight * proxy_box_iou
+ + time_weight * time_similarity
+ + positive_weight * positive_similarity
+ + negative_weight * negative_similarity
+ )
+ else:
+ box_weight, positive_weight, negative_weight = image_weights
+ accuracy = (
+ box_weight * proxy_box_iou
+ + positive_weight * positive_similarity
+ + negative_weight * negative_similarity
+ )
+ if modality == "video" and require_video_time and predicted_time is None:
+ accuracy = 0.0
+ accuracy = max(0.0, min(1.0, accuracy))
+
+ result = {
+ "overall": float(accuracy * format_score),
+ "accuracy": float(accuracy),
+ "format": float(format_score),
+ "box_iou": float(proxy_box_iou),
+ "pos_sim": float(positive_similarity),
+ "neg_sim": float(negative_similarity),
+ "time_sim": float(time_similarity),
+ "mask_aware_used": float(mask_score is not None),
+ }
+ if mask_score is not None:
+ result.update(
+ {
+ "mask_box_iou": mask_score["mask_box_iou"],
+ "mask_pos_inside": mask_score["mask_pos_inside"],
+ "mask_neg_outside": mask_score["mask_neg_outside"],
+ }
+ )
+ results.append(result)
+ return results
+
+
+def build_oracle_response_from_ground_truth(
+ ground_truth: Any,
+ extra: Any = None,
+) -> str:
+ del extra
+ payload = _mapping(ground_truth)
+ if payload is None:
+ raise RewardContractError("Segmentation ground truth must be a structured JSON object.")
+ return canonical_answer(payload)
diff --git a/code/orarl/rewards/adapters/spatial_grounding.py b/code/orarl/rewards/adapters/spatial_grounding.py
new file mode 100644
index 0000000000000000000000000000000000000000..95fef2608c92ef434003fd4c7ea63c627dd03a95
--- /dev/null
+++ b/code/orarl/rewards/adapters/spatial_grounding.py
@@ -0,0 +1,124 @@
+"""Built-in single-box spatial-grounding reward."""
+
+from __future__ import annotations
+
+import json
+import re
+from collections.abc import Mapping
+from typing import Any
+
+from ..types import RewardContractError
+from ._common import (
+ answer_payload,
+ box_iou,
+ canonical_json,
+ exact_answer_payload,
+ final_response_text,
+ normalize_box,
+ parse_json,
+ unfence,
+)
+
+REWARD_NAME = "spatial_grounding"
+REWARD_TYPE = "batch"
+CANONICAL_RESPONSE_FORMAT = "qwen_json"
+ACCEPT_TAGGED_RESPONSE = True
+
+_NUMBER = r"[-+]?(?:\d+(?:\.\d+)?|\.\d+)"
+_POINT_BOX_RE = re.compile(
+ rf"\(\s*({_NUMBER})\s*,\s*({_NUMBER})\s*\)"
+ rf"\s*,?\s*\(\s*({_NUMBER})\s*,\s*({_NUMBER})\s*\)"
+)
+_JSON_FENCE_RE = re.compile(
+ r"\A\s*```json\s*(.*?)\s*```\s*\Z",
+ flags=re.DOTALL | re.IGNORECASE,
+)
+
+
+def _box(value: Any) -> list[float] | None:
+ payload = parse_json(value)
+ box = normalize_box(payload)
+ if box is not None:
+ return box
+
+ text = answer_payload(value)
+ match = _POINT_BOX_RE.search(text)
+ if match is not None:
+ return [float(number) for number in match.groups()]
+ numbers = re.findall(_NUMBER, text)
+ if len(numbers) >= 4:
+ return [float(number) for number in numbers[-4:]]
+ return None
+
+
+def _native_box(value: Any) -> list[float] | None:
+ match = _JSON_FENCE_RE.fullmatch(str(value or ""))
+ if match is None:
+ return None
+ try:
+ payload = json.loads(match.group(1))
+ except (TypeError, ValueError):
+ return None
+ if not (
+ isinstance(payload, list)
+ and len(payload) == 1
+ and isinstance(payload[0], Mapping)
+ and "bbox_2d" in payload[0]
+ ):
+ return None
+ return normalize_box(payload[0]["bbox_2d"])
+
+
+def _prediction(
+ response: Any,
+ *,
+ accept_tagged_response: bool,
+) -> tuple[list[float] | None, float]:
+ final_text = final_response_text(response)
+ native = _native_box(final_text)
+ if native is not None:
+ return native, 1.0
+
+ tagged = exact_answer_payload(response)
+ if accept_tagged_response and tagged is not None:
+ tagged_box = _box(tagged)
+ if tagged_box is not None:
+ return tagged_box, 1.0
+ return _box(unfence(final_text)), 0.0
+
+
+def compute_score(
+ batch: list[dict[str, Any]],
+ **kwargs: Any,
+) -> list[dict[str, float]]:
+ accept_tagged = bool(kwargs.get("accept_tagged_response", ACCEPT_TAGGED_RESPONSE))
+ results: list[dict[str, float]] = []
+ for item in batch:
+ prediction, format_score = _prediction(
+ item.get("response"),
+ accept_tagged_response=accept_tagged,
+ )
+ target = _box(
+ item.get("ground_truth") if item.get("ground_truth") is not None else item.get("answer")
+ )
+ iou = box_iou(prediction, target)
+ results.append(
+ {
+ "overall": float(iou * format_score),
+ "iou": float(iou),
+ "format": float(format_score),
+ }
+ )
+ return results
+
+
+def build_oracle_response_from_ground_truth(
+ ground_truth: Any,
+ extra: Any = None,
+) -> str:
+ del extra
+ box = _box(ground_truth)
+ if box is None:
+ raise RewardContractError("Spatial-grounding ground truth must contain one bounding box.")
+ payload = [{"bbox_2d": box, "label": ""}]
+ return f"```json\n{canonical_json(payload)}\n```"
diff --git a/code/orarl/rewards/adapters/spatial_intelligence.py b/code/orarl/rewards/adapters/spatial_intelligence.py
new file mode 100644
index 0000000000000000000000000000000000000000..c4234a08436e706f60c5a55145401468ccbf0083
--- /dev/null
+++ b/code/orarl/rewards/adapters/spatial_intelligence.py
@@ -0,0 +1,178 @@
+"""Built-in VSI reward: numerical MRA and exact categorical matching."""
+
+from __future__ import annotations
+
+import math
+import re
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+from ..types import RewardContractError
+from ._common import answer_payload, canonical_answer, exact_answer_payload, ground_truth
+
+REWARD_NAME = "spatial_intelligence"
+REWARD_TYPE = "batch"
+
+NUMERICAL_SUBTYPES = frozenset(
+ {
+ "object_abs_distance",
+ "object_counting",
+ "object_size_estimation",
+ "room_size_estimation",
+ }
+)
+MULTIPLE_CHOICE_SUBTYPES = frozenset(
+ {
+ "object_rel_distance",
+ "route_planning",
+ "obj_appearance_order",
+ }
+)
+DIRECTION_PREFIX = "object_rel_direction"
+MRA_THRESHOLDS = tuple(0.50 + 0.05 * index for index in range(10))
+
+_NUMBER_RE = re.compile(r"[-+]?(?:\d+(?:\.\d+)?|\.\d+)")
+_SCALAR_RE = re.compile(r"\A\s*[-+]?(?:\d+(?:\.\d+)?|\.\d+)\s*\Z")
+_OPTION_RE = re.compile(r"\A\s*([A-D])\s*\Z", flags=re.IGNORECASE)
+
+
+def _slug(value: Any) -> str:
+ return re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
+
+
+def _is_direction(subtype: str) -> bool:
+ return subtype.startswith(DIRECTION_PREFIX)
+
+
+def _subtype(item: Mapping[str, Any]) -> str:
+ for field in (
+ "problem_type",
+ "question_type",
+ "task_name",
+ "task",
+ "scoring_family",
+ ):
+ candidate = _slug(item.get(field))
+ if (
+ candidate in NUMERICAL_SUBTYPES
+ or candidate in MULTIPLE_CHOICE_SUBTYPES
+ or _is_direction(candidate)
+ ):
+ return candidate
+ return ""
+
+
+def _number(value: Any) -> float | None:
+ text = answer_payload(value)
+ match = _NUMBER_RE.search(text)
+ if match is None:
+ return None
+ try:
+ number = float(match.group(0))
+ except (TypeError, ValueError):
+ return None
+ return number if math.isfinite(number) else None
+
+
+def _option(value: Any, *, relaxed: bool = False) -> str:
+ payload = answer_payload(value)
+ match = _OPTION_RE.fullmatch(payload)
+ if match is None and relaxed:
+ matches = re.findall(r"\b([A-D])\b", payload, flags=re.IGNORECASE)
+ return matches[-1].upper() if matches else ""
+ return match.group(1).upper() if match is not None else ""
+
+
+def mean_relative_accuracy(
+ prediction: float,
+ target: float,
+ thresholds: Sequence[float] = MRA_THRESHOLDS,
+) -> float:
+ """Average correctness over VSI confidence thresholds 0.50 through 0.95."""
+
+ if target == 0.0 or not thresholds:
+ return 0.0
+ relative_error = abs(prediction - target) / abs(target)
+ return sum(relative_error <= 1.0 - float(threshold) for threshold in thresholds) / len(
+ thresholds
+ )
+
+
+def _thresholds(value: Any) -> tuple[float, ...]:
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
+ raise ValueError("mra_thresholds must be a non-empty numeric sequence.")
+ try:
+ thresholds = tuple(float(item) for item in value)
+ except (TypeError, ValueError) as error:
+ raise ValueError("mra_thresholds must contain numbers.") from error
+ if not thresholds or any(
+ not math.isfinite(item) or not 0.0 <= item <= 1.0 for item in thresholds
+ ):
+ raise ValueError("mra_thresholds must contain values from zero to one.")
+ return thresholds
+
+
+def compute_score(
+ batch: list[dict[str, Any]],
+ **kwargs: Any,
+) -> list[dict[str, float]]:
+ thresholds = _thresholds(kwargs.get("mra_thresholds", MRA_THRESHOLDS))
+ results: list[dict[str, float]] = []
+ for item in batch:
+ subtype = _subtype(item)
+ response_payload = exact_answer_payload(item.get("response"))
+ target_value = ground_truth(item)
+
+ if subtype in NUMERICAL_SUBTYPES:
+ format_score = float(
+ response_payload is not None and _SCALAR_RE.fullmatch(response_payload) is not None
+ )
+ prediction = _number(response_payload)
+ target = _number(target_value)
+ accuracy = (
+ mean_relative_accuracy(prediction, target, thresholds)
+ if prediction is not None and target is not None
+ else 0.0
+ )
+ metric_name = "mra"
+ elif subtype in MULTIPLE_CHOICE_SUBTYPES or _is_direction(subtype):
+ prediction = _option(response_payload)
+ target = _option(target_value, relaxed=True)
+ format_score = float(bool(prediction))
+ accuracy = float(bool(target) and prediction == target)
+ metric_name = "acc"
+ else:
+ format_score = 0.0
+ accuracy = 0.0
+ metric_name = "accuracy"
+
+ result = {
+ "overall": float(accuracy * format_score),
+ "accuracy": float(accuracy),
+ "format": float(format_score),
+ }
+ result[metric_name] = float(accuracy)
+ results.append(result)
+ return results
+
+
+def build_oracle_response_from_ground_truth(
+ ground_truth: Any,
+ extra: Mapping[str, Any] | None = None,
+) -> str:
+ payload = answer_payload(ground_truth).strip()
+ if not payload:
+ raise RewardContractError("Spatial-intelligence ground truth must contain an answer.")
+ subtype = _subtype(extra or {})
+ if subtype in NUMERICAL_SUBTYPES:
+ number = _number(payload)
+ if number is None:
+ raise RewardContractError(
+ "Numerical spatial-intelligence ground truth must contain a number."
+ )
+ payload = f"{number:g}"
+ elif subtype in MULTIPLE_CHOICE_SUBTYPES or _is_direction(subtype):
+ option = _option(payload, relaxed=True)
+ if option:
+ payload = option
+ return canonical_answer(payload)
diff --git a/code/orarl/rewards/adapters/spatial_temporal_grounding.py b/code/orarl/rewards/adapters/spatial_temporal_grounding.py
new file mode 100644
index 0000000000000000000000000000000000000000..12b716a0c153608b245828c1c8070d79da242356
--- /dev/null
+++ b/code/orarl/rewards/adapters/spatial_temporal_grounding.py
@@ -0,0 +1,147 @@
+"""Built-in spatial-temporal grounding reward aligned with strict evaluation."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+from ..types import RewardContractError
+from ._common import (
+ box_iou,
+ canonical_answer,
+ exact_answer_payload,
+ finite_float,
+ ground_truth,
+ interval_iou,
+ normalize_box,
+ normalize_boxes,
+ parse_mapping,
+)
+
+REWARD_NAME = "spatial_temporal_grounding"
+REWARD_TYPE = "batch"
+TEMPORAL_IOU_WEIGHT = 0.2
+STRICT_SPATIAL_IOU_WEIGHT = 0.8
+
+
+def _time_span(value: Any) -> list[float] | None:
+ if not (
+ isinstance(value, Sequence) and not isinstance(value, (str, bytes)) and len(value) == 2
+ ):
+ return None
+ span = [finite_float(part) for part in value]
+ if any(part is None for part in span):
+ return None
+ return [float(part) for part in span]
+
+
+def _boxes(value: Any, time_span: Any) -> dict[str, list[float]]:
+ boxes = normalize_boxes(value)
+ if boxes or not isinstance(value, list):
+ return boxes
+ span = _time_span(time_span)
+ if span is None:
+ return {}
+ start = int(round(span[0]))
+ return {
+ str(start + index): box
+ for index, raw_box in enumerate(value)
+ if (box := normalize_box(raw_box)) is not None
+ }
+
+
+def _payload(value: Any) -> tuple[list[float] | None, dict[str, list[float]]]:
+ payload = parse_mapping(value)
+ if payload is None:
+ return None, {}
+ span = _time_span(payload.get("time"))
+ return span, _boxes(payload.get("boxes"), span)
+
+
+def _prediction(
+ value: Any,
+) -> tuple[list[float] | None, dict[str, list[float]], float]:
+ answer = exact_answer_payload(value)
+ if answer is None:
+ return None, {}, 0.0
+ try:
+ payload = json.loads(answer)
+ except (TypeError, ValueError):
+ return None, {}, 0.0
+ if not isinstance(payload, Mapping):
+ return None, {}, 0.0
+ span = _time_span(payload.get("time"))
+ raw_boxes = payload.get("boxes")
+ boxes = normalize_boxes(raw_boxes)
+ valid = (
+ span is not None
+ and span[0] <= span[1]
+ and isinstance(raw_boxes, Mapping)
+ and bool(boxes)
+ and len(boxes) == len(raw_boxes)
+ )
+ return span, boxes, float(valid)
+
+
+def strict_spatial_iou(
+ predicted_boxes: Mapping[str, Any],
+ target_boxes: Mapping[str, Any],
+) -> float:
+ """Average box IoU over every target frame, including missing-frame zeros."""
+
+ if not target_boxes:
+ return 0.0
+ return sum(
+ box_iou(predicted_boxes.get(frame), target_box)
+ for frame, target_box in target_boxes.items()
+ ) / len(target_boxes)
+
+
+def compute_score(
+ batch: list[dict[str, Any]],
+ **kwargs: Any,
+) -> list[dict[str, float]]:
+ temporal_weight = finite_float(kwargs.get("temporal_iou_weight", TEMPORAL_IOU_WEIGHT))
+ if temporal_weight is None or not 0.0 <= temporal_weight <= 1.0:
+ raise ValueError("temporal_iou_weight must be between zero and one.")
+ spatial_weight = 1.0 - temporal_weight
+
+ results: list[dict[str, float]] = []
+ for item in batch:
+ predicted_time, predicted_boxes, format_score = _prediction(item.get("response"))
+ target_time, target_boxes = _payload(ground_truth(item))
+ temporal_iou = interval_iou(predicted_time, target_time)
+ spatial_iou = strict_spatial_iou(predicted_boxes, target_boxes)
+ coverage = (
+ sum(frame in predicted_boxes for frame in target_boxes) / len(target_boxes)
+ if target_boxes
+ else 0.0
+ )
+ accuracy = temporal_weight * temporal_iou + spatial_weight * spatial_iou
+ results.append(
+ {
+ "overall": float(accuracy * format_score),
+ "accuracy": float(accuracy),
+ "format": float(format_score),
+ "tiou": float(temporal_iou),
+ "siou_strict": float(spatial_iou),
+ "coverage": float(coverage),
+ }
+ )
+ return results
+
+
+def build_oracle_response_from_ground_truth(
+ ground_truth: Any,
+ extra: Any = None,
+) -> str:
+ del extra
+ span, boxes = _payload(ground_truth)
+ if span is None or not boxes:
+ raise RewardContractError(
+ "Spatial-temporal ground truth must contain time and frame-keyed boxes."
+ )
+ if span[0] > span[1]:
+ span = [span[1], span[0]]
+ return canonical_answer({"time": span, "boxes": boxes})
diff --git a/code/orarl/rewards/adapters/temporal_grounding.py b/code/orarl/rewards/adapters/temporal_grounding.py
new file mode 100644
index 0000000000000000000000000000000000000000..5255d1f3e21ea3fd7e5fac1898daa12b5b1b1964
--- /dev/null
+++ b/code/orarl/rewards/adapters/temporal_grounding.py
@@ -0,0 +1,111 @@
+"""Built-in temporal-grounding reward using temporal intersection-over-union."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+from ..types import RewardContractError
+from ._common import (
+ canonical_answer,
+ exact_answer_payload,
+ finite_float,
+ ground_truth,
+ interval_iou,
+ parse_json,
+)
+
+REWARD_NAME = "temporal_grounding"
+REWARD_TYPE = "batch"
+RESPONSE_SEPARATOR = "to"
+
+_NUMBER = r"[-+]?(?:\d+(?:\.\d+)?|\.\d+)"
+_SPAN_RE = re.compile(
+ rf"\A\s*({_NUMBER})\s+(?:to|and)\s+({_NUMBER})\s*\Z",
+ flags=re.IGNORECASE,
+)
+_LOOSE_SPAN_RE = re.compile(
+ rf"({_NUMBER})\s*(?:to|and|[-–—])\s*({_NUMBER})",
+ flags=re.IGNORECASE,
+)
+
+
+def _span(value: Any) -> list[float] | None:
+ payload = parse_json(value)
+ if isinstance(payload, Mapping):
+ if "time" in payload:
+ return _span(payload["time"])
+ if "start" in payload and "end" in payload:
+ return _span([payload["start"], payload["end"]])
+ if (
+ isinstance(payload, Sequence)
+ and not isinstance(payload, (str, bytes))
+ and len(payload) == 2
+ ):
+ numbers = [finite_float(item) for item in payload]
+ if all(number is not None for number in numbers):
+ return [float(number) for number in numbers]
+
+ match = _LOOSE_SPAN_RE.search(str(value or ""))
+ if match is None:
+ return None
+ numbers = [finite_float(part) for part in match.groups()]
+ if any(number is None for number in numbers):
+ return None
+ return [float(number) for number in numbers]
+
+
+def _prediction(value: Any) -> tuple[list[float] | None, float]:
+ payload = exact_answer_payload(value)
+ if payload is None:
+ return None, 0.0
+ match = _SPAN_RE.fullmatch(payload)
+ if match is None:
+ return None, 0.0
+ span = [float(number) for number in match.groups()]
+ if span[0] > span[1]:
+ return None, 0.0
+ return span, 1.0
+
+
+def compute_score(
+ batch: list[dict[str, Any]],
+ **kwargs: Any,
+) -> list[dict[str, float]]:
+ """Score canonical ``start to end `` responses."""
+
+ del kwargs
+ results: list[dict[str, float]] = []
+ for item in batch:
+ prediction, format_score = _prediction(item.get("response"))
+ target = _span(ground_truth(item))
+ iou = (
+ interval_iou(prediction, target)
+ if prediction is not None and target is not None
+ else 0.0
+ )
+ results.append(
+ {
+ "overall": float(iou * format_score),
+ "iou": float(iou),
+ "format": float(format_score),
+ }
+ )
+ return results
+
+
+def build_oracle_response_from_ground_truth(
+ ground_truth: Any,
+ extra: Any = None,
+) -> str:
+ """Build the canonical temporal response used by oracle injection."""
+
+ del extra
+ span = _span(ground_truth)
+ if span is None:
+ raise RewardContractError("Temporal-grounding ground truth must contain a start/end span.")
+ start, end = span
+ if start > end:
+ start, end = end, start
+ return canonical_answer(f"{start:g} {RESPONSE_SEPARATOR} {end:g}")
diff --git a/code/orarl/rewards/adapters/tracking.py b/code/orarl/rewards/adapters/tracking.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a01c12c81d792045ecf468f5e9d9fd59794c809
--- /dev/null
+++ b/code/orarl/rewards/adapters/tracking.py
@@ -0,0 +1,90 @@
+"""Built-in tracking reward aligned with strict average overlap evaluation."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping
+from typing import Any
+
+from ..types import RewardContractError
+from ._common import (
+ box_iou,
+ canonical_answer,
+ exact_answer_payload,
+ ground_truth,
+ normalize_boxes,
+ parse_mapping,
+)
+
+REWARD_NAME = "tracking"
+REWARD_TYPE = "batch"
+
+
+def _boxes(value: Any) -> dict[str, list[float]]:
+ payload = parse_mapping(value)
+ return normalize_boxes(payload.get("boxes")) if payload is not None else {}
+
+
+def _prediction(value: Any) -> tuple[dict[str, list[float]], float]:
+ answer = exact_answer_payload(value)
+ if answer is None:
+ return {}, 0.0
+ try:
+ payload = json.loads(answer)
+ except (TypeError, ValueError):
+ return {}, 0.0
+ if not isinstance(payload, Mapping) or not isinstance(payload.get("boxes"), Mapping):
+ return {}, 0.0
+ raw_boxes = payload["boxes"]
+ boxes = normalize_boxes(raw_boxes)
+ valid_shape = bool(boxes) and len(boxes) == len(raw_boxes)
+ return boxes, float(valid_shape)
+
+
+def strict_mean_iou(
+ predicted_boxes: Mapping[str, Any],
+ target_boxes: Mapping[str, Any],
+) -> float:
+ """Mean box IoU over every target frame; missing predictions contribute zero."""
+
+ if not target_boxes:
+ return 0.0
+ total = sum(
+ box_iou(predicted_boxes.get(frame), target_box)
+ for frame, target_box in target_boxes.items()
+ )
+ return total / len(target_boxes)
+
+
+def compute_score(
+ batch: list[dict[str, Any]],
+ **kwargs: Any,
+) -> list[dict[str, float]]:
+ del kwargs
+ results: list[dict[str, float]] = []
+ for item in batch:
+ predicted, format_score = _prediction(item.get("response"))
+ target = _boxes(ground_truth(item))
+ mean_iou = strict_mean_iou(predicted, target)
+ coverage = sum(frame in predicted for frame in target) / len(target) if target else 0.0
+ results.append(
+ {
+ "overall": float(mean_iou * format_score),
+ "accuracy": float(mean_iou),
+ "format": float(format_score),
+ "miou": float(mean_iou),
+ "coverage": float(coverage),
+ }
+ )
+ return results
+
+
+def build_oracle_response_from_ground_truth(
+ ground_truth: Any,
+ extra: Any = None,
+) -> str:
+ del extra
+ boxes = _boxes(ground_truth)
+ if not boxes:
+ raise RewardContractError("Tracking ground truth must contain non-empty frame-keyed boxes.")
+ return canonical_answer({"boxes": boxes})
diff --git a/code/orarl/rewards/adapters/video_qa.py b/code/orarl/rewards/adapters/video_qa.py
new file mode 100644
index 0000000000000000000000000000000000000000..3937319200830dc704fd998fc5280dcd486c6f9b
--- /dev/null
+++ b/code/orarl/rewards/adapters/video_qa.py
@@ -0,0 +1,100 @@
+"""Built-in exact A-through-H Video-QA adapter."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping
+from typing import Any
+
+from ..types import RewardContractError
+from ._common import answer_payload, canonical_answer, exact_answer_payload, ground_truth
+
+REWARD_NAME = "video_qa"
+REWARD_TYPE = "batch"
+DEFAULT_OPTIONS = "ABCDEFGH"
+REQUIRE_ANSWER_TAGS = True
+
+
+class VideoQAAdapter:
+ """Configurable exact-option scorer used by the public router."""
+
+ def __init__(
+ self,
+ options: str = DEFAULT_OPTIONS,
+ require_answer_tags: bool = REQUIRE_ANSWER_TAGS,
+ ) -> None:
+ normalized = "".join(dict.fromkeys(str(options).upper()))
+ if not normalized or any(not ("A" <= option <= "Z") for option in normalized):
+ raise ValueError("Video-QA options must contain ASCII option letters.")
+ self.options = normalized
+ self.require_answer_tags = bool(require_answer_tags)
+ choices = re.escape(normalized)
+ self._option_re = re.compile(
+ rf"\A\s*([{choices}])\s*\Z",
+ flags=re.IGNORECASE,
+ )
+
+ def _option(self, value: Any, *, response: bool) -> str:
+ payload = exact_answer_payload(value)
+ if payload is None and (not response or not self.require_answer_tags):
+ payload = answer_payload(value)
+ match = self._option_re.fullmatch(str(payload or ""))
+ return match.group(1).upper() if match is not None else ""
+
+ def compute_score(
+ self,
+ batch: list[dict[str, Any]],
+ **kwargs: Any,
+ ) -> list[dict[str, float]]:
+ del kwargs
+ results: list[dict[str, float]] = []
+ for item in batch:
+ prediction = self._option(item.get("response"), response=True)
+ target = self._option(ground_truth(item), response=False)
+ format_score = float(bool(prediction))
+ accuracy = float(bool(prediction) and bool(target) and prediction == target)
+ results.append(
+ {
+ "overall": float(accuracy * format_score),
+ "accuracy": float(accuracy),
+ "format": float(format_score),
+ }
+ )
+ return results
+
+ def build_oracle_response_from_ground_truth(
+ self,
+ ground_truth: Any,
+ extra: Mapping[str, Any] | None = None,
+ ) -> str:
+ del extra
+ option = self._option(ground_truth, response=False)
+ if not option:
+ raise RewardContractError(
+ "Video-QA ground truth must be exactly one configured option."
+ )
+ return canonical_answer(option)
+
+
+_DEFAULT_ADAPTER = VideoQAAdapter()
+
+
+def compute_score(
+ batch: list[dict[str, Any]],
+ **kwargs: Any,
+) -> list[dict[str, float]]:
+ options = kwargs.pop("options", DEFAULT_OPTIONS)
+ require_tags = kwargs.pop("require_answer_tags", REQUIRE_ANSWER_TAGS)
+ if options == DEFAULT_OPTIONS and require_tags is REQUIRE_ANSWER_TAGS:
+ return _DEFAULT_ADAPTER.compute_score(batch, **kwargs)
+ return VideoQAAdapter(options, require_tags).compute_score(batch, **kwargs)
+
+
+def build_oracle_response_from_ground_truth(
+ ground_truth: Any,
+ extra: Mapping[str, Any] | None = None,
+) -> str:
+ return _DEFAULT_ADAPTER.build_oracle_response_from_ground_truth(
+ ground_truth,
+ extra,
+ )
diff --git a/code/orarl/rewards/router.py b/code/orarl/rewards/router.py
new file mode 100644
index 0000000000000000000000000000000000000000..7aa78b0000889e2336824797b955927bd6023747
--- /dev/null
+++ b/code/orarl/rewards/router.py
@@ -0,0 +1,685 @@
+"""Lightweight task router for the seven OraRL reward families."""
+
+from __future__ import annotations
+
+import importlib
+import inspect
+import json
+import math
+import re
+from collections import defaultdict
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass, field
+from types import MappingProxyType
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+from .types import (
+ RewardAdapter,
+ RewardContractError,
+ RewardResult,
+ TaskFamily,
+ UnknownTaskError,
+)
+
+REWARD_NAME = "orarl"
+REWARD_TYPE = "batch"
+
+DEFAULT_MODULE_PATHS: Mapping[TaskFamily, str] = MappingProxyType(
+ {
+ TaskFamily.TEMPORAL_GROUNDING: ("orarl.rewards.adapters.temporal_grounding"),
+ TaskFamily.TRACKING: "orarl.rewards.adapters.tracking",
+ TaskFamily.SEGMENTATION: "orarl.rewards.adapters.segmentation",
+ TaskFamily.SPATIAL_GROUNDING: ("orarl.rewards.adapters.spatial_grounding"),
+ TaskFamily.SPATIAL_TEMPORAL_GROUNDING: (
+ "orarl.rewards.adapters.spatial_temporal_grounding"
+ ),
+ TaskFamily.SPATIAL_INTELLIGENCE: ("orarl.rewards.adapters.spatial_intelligence"),
+ TaskFamily.VIDEO_QA: "orarl.rewards.adapters.video_qa",
+ }
+)
+
+
+def _default_module_paths() -> Dict[TaskFamily, str]:
+ return dict(DEFAULT_MODULE_PATHS)
+
+
+_BUILTIN_ALIASES: Dict[str, TaskFamily] = {}
+
+
+def _slug(value: Any) -> str:
+ text = str(value or "").strip().lower()
+ return re.sub(r"[^a-z0-9]+", "_", text).strip("_")
+
+
+def _register_aliases(family: TaskFamily, *names: str) -> None:
+ for name in names:
+ _BUILTIN_ALIASES[_slug(name)] = family
+
+
+_register_aliases(
+ TaskFamily.TEMPORAL_GROUNDING,
+ TaskFamily.TEMPORAL_GROUNDING.value,
+ "temporal grounding",
+ "temporal",
+ "timelens",
+ "time lens",
+ "moment retrieval",
+ "temporal localization",
+ "video temporal grounding",
+)
+_register_aliases(
+ TaskFamily.TRACKING,
+ TaskFamily.TRACKING.value,
+ "video tracking",
+ "object tracking",
+ "single object tracking",
+ "sot",
+)
+_register_aliases(
+ TaskFamily.SEGMENTATION,
+ TaskFamily.SEGMENTATION.value,
+ "image segmentation",
+ "video segmentation",
+ "referring segmentation",
+ "mask segmentation",
+)
+_register_aliases(
+ TaskFamily.SPATIAL_GROUNDING,
+ TaskFamily.SPATIAL_GROUNDING.value,
+ "spatial grounding",
+ "spatial",
+ "visual grounding",
+ "image grounding",
+ "bbox grounding",
+ "refcoco",
+ "referring expression comprehension",
+ "rec",
+)
+_register_aliases(
+ TaskFamily.SPATIAL_TEMPORAL_GROUNDING,
+ TaskFamily.SPATIAL_TEMPORAL_GROUNDING.value,
+ "spatial temporal grounding",
+ "spatial-temporal grounding",
+ "spatio temporal grounding",
+ "spatiotemporal grounding",
+ "video spatial temporal grounding",
+ "video grounding",
+ "stvg",
+)
+_register_aliases(
+ TaskFamily.SPATIAL_INTELLIGENCE,
+ TaskFamily.SPATIAL_INTELLIGENCE.value,
+ "spatial intelligence",
+ "video spatial intelligence",
+ "spatial reasoning",
+ "vsi",
+ "vsi bench",
+ "object abs distance",
+ "object counting",
+ "object size estimation",
+ "room size estimation",
+ "object rel distance",
+ "route planning",
+ "obj appearance order",
+)
+_register_aliases(
+ TaskFamily.VIDEO_QA,
+ TaskFamily.VIDEO_QA.value,
+ "video qa",
+ "video question answering",
+ "video multiple choice",
+ "video mc",
+ "video_qa_mc",
+ "multiple choice",
+ "multiple_choice",
+ "mc",
+ "image_sequence_mc_answer_only",
+)
+
+
+_SPATIAL_INTELLIGENCE_PREFIXES: Tuple[str, ...] = ("object_rel_direction",)
+
+
+def _normalize_with_aliases(
+ value: Any,
+ aliases: Mapping[str, TaskFamily],
+) -> TaskFamily:
+ if isinstance(value, TaskFamily):
+ return value
+ normalized = _slug(value)
+ family = aliases.get(normalized)
+ if family is not None:
+ return family
+ if normalized.startswith(_SPATIAL_INTELLIGENCE_PREFIXES):
+ return TaskFamily.SPATIAL_INTELLIGENCE
+ supported = ", ".join(family.value for family in TaskFamily)
+ raise UnknownTaskError(f"Unknown reward task {value!r}. Supported families: {supported}.")
+
+
+def normalize_task_name(value: Any) -> TaskFamily:
+ """Normalize a public task name or a dataset ``problem_type``."""
+
+ return _normalize_with_aliases(value, _BUILTIN_ALIASES)
+
+
+@dataclass(frozen=True)
+class RouterSettings:
+ """Explicit router configuration.
+
+ Packaged reward modules are imported only when their family is first scored
+ or asked to build an oracle response. Adapter and module-path overrides can
+ replace any family without environment configuration.
+ """
+
+ module_paths: Mapping[Union[TaskFamily, str], str] = field(
+ default_factory=_default_module_paths
+ )
+ adapters: Mapping[Union[TaskFamily, str], RewardAdapter] = field(default_factory=dict)
+ task_aliases: Mapping[str, Union[TaskFamily, str]] = field(default_factory=dict)
+ score_kwargs: Mapping[Union[TaskFamily, str], Mapping[str, Any]] = field(default_factory=dict)
+ task_fields: Tuple[str, ...] = (
+ "task",
+ "task_name",
+ "problem_type",
+ "task_source",
+ "scoring_family",
+ "question_type",
+ )
+ ground_truth_fields: Tuple[str, ...] = ("ground_truth", "answer")
+ video_qa_options: str = "ABCDEFGH"
+ video_qa_require_answer_tags: bool = True
+
+
+class RewardRouter:
+ """Route typed samples to lazy task-specific reward adapters."""
+
+ def __init__(
+ self,
+ settings: Optional[RouterSettings] = None,
+ *,
+ importer: Optional[Callable[[str], Any]] = None,
+ ) -> None:
+ self.settings = settings or RouterSettings()
+ if not self.settings.task_fields:
+ raise ValueError("RouterSettings.task_fields cannot be empty.")
+ if not self.settings.ground_truth_fields:
+ raise ValueError("RouterSettings.ground_truth_fields cannot be empty.")
+
+ self._aliases = dict(_BUILTIN_ALIASES)
+ for alias, target in self.settings.task_aliases.items():
+ family = _normalize_with_aliases(target, self._aliases)
+ self._aliases[_slug(alias)] = family
+
+ self._module_paths = _default_module_paths()
+ for key, module_path in self.settings.module_paths.items():
+ family = _normalize_with_aliases(key, self._aliases)
+ if not isinstance(module_path, str) or not module_path.strip():
+ raise ValueError(f"Module path for {family.value!r} must be non-empty.")
+ self._module_paths[family] = module_path.strip()
+
+ self._adapter_overrides: Dict[TaskFamily, RewardAdapter] = {}
+ for key, adapter in self.settings.adapters.items():
+ family = _normalize_with_aliases(key, self._aliases)
+ self._adapter_overrides[family] = adapter
+
+ self._score_kwargs: Dict[TaskFamily, Dict[str, Any]] = {}
+ for key, task_kwargs in self.settings.score_kwargs.items():
+ family = _normalize_with_aliases(key, self._aliases)
+ self._score_kwargs[family] = dict(task_kwargs)
+
+ self._importer = importer
+ self._adapter_cache: Dict[TaskFamily, RewardAdapter] = {}
+
+ def clear_cache(self) -> None:
+ """Forget lazily imported modules, primarily for worker reconfiguration."""
+
+ self._adapter_cache.clear()
+
+ def normalize_task(self, value: Any) -> TaskFamily:
+ """Normalize a task using built-in and configured aliases."""
+
+ return _normalize_with_aliases(value, self._aliases)
+
+ def resolve_task(self, sample: Mapping[str, Any]) -> TaskFamily:
+ """Resolve the first recognized task field in a sample."""
+
+ if not isinstance(sample, Mapping):
+ raise TypeError("Reward samples must be mappings.")
+
+ examined: List[str] = []
+ for field_name in self.settings.task_fields:
+ if field_name not in sample:
+ continue
+ value = sample.get(field_name)
+ if value is None or not str(value).strip():
+ continue
+ examined.append(f"{field_name}={value!r}")
+ try:
+ return self.normalize_task(value)
+ except UnknownTaskError:
+ continue
+
+ details = ", ".join(examined) if examined else "no task fields"
+ supported = ", ".join(family.value for family in TaskFamily)
+ raise UnknownTaskError(
+ f"Could not route reward sample ({details}). Supported families: {supported}."
+ )
+
+ def _load_adapter(self, family: TaskFamily) -> RewardAdapter:
+ cached = self._adapter_cache.get(family)
+ if cached is not None:
+ return cached
+
+ adapter = self._adapter_overrides.get(family)
+ if adapter is None:
+ module_path = self._module_paths.get(family)
+ if module_path is None:
+ raise RewardContractError(f"No adapter module is configured for {family.value!r}.")
+ importer = self._importer or importlib.import_module
+ adapter = importer(module_path)
+ if (
+ family is TaskFamily.VIDEO_QA
+ and module_path == DEFAULT_MODULE_PATHS[TaskFamily.VIDEO_QA]
+ ):
+ factory = getattr(adapter, "VideoQAAdapter", None)
+ if callable(factory):
+ adapter = factory(
+ self.settings.video_qa_options,
+ self.settings.video_qa_require_answer_tags,
+ )
+
+ scorer = getattr(adapter, "compute_score", None)
+ if not callable(scorer):
+ raise RewardContractError(
+ f"Adapter for {family.value!r} has no callable compute_score."
+ )
+ self._adapter_cache[family] = adapter
+ return adapter
+
+ @staticmethod
+ def _normalize_metrics(
+ family: TaskFamily,
+ raw_metrics: Any,
+ ) -> RewardResult:
+ if not isinstance(raw_metrics, Mapping):
+ raise RewardContractError(f"{family.value} adapter returned a non-mapping score.")
+ if "overall" not in raw_metrics:
+ raise RewardContractError(f"{family.value} adapter score is missing 'overall'.")
+
+ metrics: RewardResult = {}
+ for key, value in raw_metrics.items():
+ if not isinstance(key, str):
+ raise RewardContractError(
+ f"{family.value} adapter returned a non-string metric key."
+ )
+ if isinstance(value, (str, bytes)) or value is None:
+ raise RewardContractError(f"{family.value} metric {key!r} must be numeric.")
+ try:
+ number = float(value)
+ except (TypeError, ValueError) as error:
+ raise RewardContractError(
+ f"{family.value} metric {key!r} must be numeric."
+ ) from error
+ if not math.isfinite(number):
+ raise RewardContractError(f"{family.value} metric {key!r} must be finite.")
+ metrics[key] = number
+ return metrics
+
+ def _score_group(
+ self,
+ family: TaskFamily,
+ rows: List[Dict[str, Any]],
+ kwargs: Mapping[str, Any],
+ ) -> List[RewardResult]:
+ adapter = self._load_adapter(family)
+ call_kwargs = dict(self._score_kwargs.get(family, {}))
+ call_kwargs.update(kwargs)
+ raw_results = adapter.compute_score(rows, **call_kwargs)
+ if not isinstance(raw_results, Sequence) or isinstance(raw_results, (str, bytes)):
+ raise RewardContractError(f"{family.value} adapter returned a non-sequence batch.")
+ if len(raw_results) != len(rows):
+ raise RewardContractError(
+ f"{family.value} adapter returned {len(raw_results)} scores "
+ f"for {len(rows)} samples."
+ )
+ return [self._normalize_metrics(family, metrics) for metrics in raw_results]
+
+ def compute_reward(
+ self,
+ sample: Mapping[str, Any],
+ response: str,
+ **kwargs: Any,
+ ) -> RewardResult:
+ """Compute one task-specific reward while preserving its telemetry."""
+
+ if not isinstance(sample, Mapping):
+ raise TypeError("Reward samples must be mappings.")
+ if not isinstance(response, str):
+ raise TypeError("Reward responses must be strings.")
+ family = self.resolve_task(sample)
+ row = dict(sample)
+ row["response"] = response
+ return self._score_group(family, [row], kwargs)[0]
+
+ def compute_score(
+ self,
+ batch: Sequence[Mapping[str, Any]],
+ **kwargs: Any,
+ ) -> List[RewardResult]:
+ """Score a mixed-task batch and restore its original row order."""
+
+ if isinstance(batch, (str, bytes)) or not isinstance(batch, Sequence):
+ raise ValueError("compute_score expects a sequence of samples.")
+
+ grouped: Dict[TaskFamily, List[Tuple[int, Dict[str, Any]]]] = defaultdict(list)
+ for index, sample in enumerate(batch):
+ if not isinstance(sample, Mapping):
+ raise TypeError(f"Reward sample at index {index} must be a mapping.")
+ family = self.resolve_task(sample)
+ grouped[family].append((index, dict(sample)))
+
+ ordered: List[Optional[RewardResult]] = [None] * len(batch)
+ for family, indexed_rows in grouped.items():
+ rows = [row for _, row in indexed_rows]
+ scores = self._score_group(family, rows, kwargs)
+ for (index, _), score in zip(indexed_rows, scores):
+ ordered[index] = score
+
+ if any(score is None for score in ordered):
+ raise RewardContractError("A routed reward row was not scored.")
+ return [score for score in ordered if score is not None]
+
+ def _ground_truth(self, sample: Mapping[str, Any]) -> Any:
+ for field_name in self.settings.ground_truth_fields:
+ if field_name in sample and sample[field_name] is not None:
+ return sample[field_name]
+ names = ", ".join(self.settings.ground_truth_fields)
+ raise RewardContractError(f"Reward sample is missing ground truth; checked: {names}.")
+
+ @staticmethod
+ def _builder_for(
+ family: TaskFamily,
+ adapter: RewardAdapter,
+ ) -> Callable[..., Any]:
+ builder = getattr(
+ adapter,
+ "build_oracle_response_from_ground_truth",
+ None,
+ )
+ if callable(builder):
+ return builder
+ raise RewardContractError(f"Adapter for {family.value!r} has no oracle response builder.")
+
+ @staticmethod
+ def _call_builder(
+ family: TaskFamily,
+ builder: Callable[..., Any],
+ ground_truth: Any,
+ extra: Any,
+ ) -> Optional[str]:
+ try:
+ signature = inspect.signature(builder)
+ signature.bind(ground_truth, extra)
+ except (TypeError, ValueError):
+ result = builder(ground_truth)
+ else:
+ result = builder(ground_truth, extra)
+ if result is not None and not isinstance(result, str):
+ raise RewardContractError(
+ f"{family.value} oracle builder returned "
+ f"{type(result).__name__}, expected str or None."
+ )
+ return result
+
+ def _build_for_family(
+ self,
+ family: TaskFamily,
+ ground_truth: Any,
+ extra: Any,
+ ) -> Optional[str]:
+ adapter = self._load_adapter(family)
+ builder = self._builder_for(family, adapter)
+ return self._call_builder(family, builder, ground_truth, extra)
+
+ def build_oracle_response(
+ self,
+ sample: Mapping[str, Any],
+ ) -> Optional[str]:
+ """Build an oracle response using the sample's resolved task."""
+
+ if not isinstance(sample, Mapping):
+ raise TypeError("Reward samples must be mappings.")
+ family = self.resolve_task(sample)
+ ground_truth = self._ground_truth(sample)
+ return self._build_for_family(family, ground_truth, dict(sample))
+
+ def build_oracle_response_from_ground_truth(
+ self,
+ ground_truth: Any,
+ extra: Any = None,
+ ) -> Optional[str]:
+ """Build a trainer-compatible oracle response.
+
+ When ``extra`` carries task metadata it is authoritative. Otherwise the
+ family is inferred conservatively from the ground-truth structure.
+ """
+
+ family: Optional[TaskFamily] = None
+ if isinstance(extra, Mapping):
+ has_task_metadata = any(
+ field_name in extra
+ and extra[field_name] is not None
+ and str(extra[field_name]).strip()
+ for field_name in self.settings.task_fields
+ )
+ if has_task_metadata:
+ family = self.resolve_task(extra)
+ elif isinstance(extra, (str, TaskFamily)) and str(extra).strip():
+ family = self.normalize_task(extra)
+
+ if family is None:
+ family = infer_task_from_ground_truth(ground_truth)
+ return self._build_for_family(family, ground_truth, extra)
+
+
+_ANSWER_CONTENT_RE = re.compile(
+ r"\s*(.*?)\s* ",
+ flags=re.DOTALL | re.IGNORECASE,
+)
+_NUMBER_RE = r"[-+]?(?:\d+(?:\.\d+)?|\.\d+)"
+
+
+def _answer_content(value: Any) -> str:
+ text = str(value or "").strip()
+ matches = _ANSWER_CONTENT_RE.findall(text)
+ return matches[-1].strip() if matches else text
+
+
+def _json_payload(value: Any) -> Any:
+ if isinstance(value, (Mapping, list, tuple)):
+ return value
+ text = _answer_content(value)
+ fenced = re.fullmatch(
+ r"\s*```(?:json)?\s*(.*?)\s*```\s*",
+ text,
+ flags=re.DOTALL | re.IGNORECASE,
+ )
+ if fenced is not None:
+ text = fenced.group(1).strip()
+ try:
+ return json.loads(text)
+ except (TypeError, ValueError):
+ pass
+
+ for opening, closing in (("{", "}"), ("[", "]")):
+ start = text.find(opening)
+ end = text.rfind(closing)
+ if start < 0 or end <= start:
+ continue
+ try:
+ return json.loads(text[start : end + 1])
+ except (TypeError, ValueError):
+ continue
+ return None
+
+
+def _is_number(value: Any) -> bool:
+ if isinstance(value, bool):
+ return False
+ try:
+ return math.isfinite(float(value))
+ except (TypeError, ValueError):
+ return False
+
+
+def _is_box(value: Any) -> bool:
+ return (
+ isinstance(value, (list, tuple))
+ and len(value) == 4
+ and all(_is_number(coordinate) for coordinate in value)
+ )
+
+
+def _infer_from_json(payload: Any) -> Optional[TaskFamily]:
+ if isinstance(payload, (list, tuple)):
+ if _is_box(payload):
+ return TaskFamily.SPATIAL_GROUNDING
+ if len(payload) == 2 and all(_is_number(value) for value in payload):
+ return TaskFamily.TEMPORAL_GROUNDING
+ if payload and all(isinstance(item, Mapping) for item in payload):
+ if any(any(key in item for key in ("bbox_2d", "bbox", "box")) for item in payload):
+ return TaskFamily.SPATIAL_GROUNDING
+ return None
+
+ if not isinstance(payload, Mapping):
+ return None
+ keys = {_slug(key): key for key in payload}
+ if "positive_points" in keys or "negative_points" in keys:
+ return TaskFamily.SEGMENTATION
+
+ boxes_key = keys.get("boxes")
+ time_key = keys.get("time")
+ if boxes_key is not None and time_key is not None:
+ return TaskFamily.SPATIAL_TEMPORAL_GROUNDING
+ if boxes_key is not None:
+ boxes = payload[boxes_key]
+ if isinstance(boxes, Mapping):
+ return TaskFamily.TRACKING
+ if _is_box(boxes):
+ return TaskFamily.SPATIAL_GROUNDING
+
+ for key_name in ("bbox_2d", "bbox", "box"):
+ source_key = keys.get(key_name)
+ if source_key is not None and _is_box(payload[source_key]):
+ return TaskFamily.SPATIAL_GROUNDING
+
+ if time_key is not None:
+ time_value = payload[time_key]
+ if (
+ isinstance(time_value, (list, tuple))
+ and len(time_value) == 2
+ and all(_is_number(value) for value in time_value)
+ ):
+ return TaskFamily.TEMPORAL_GROUNDING
+ if "start" in keys and "end" in keys:
+ return TaskFamily.TEMPORAL_GROUNDING
+ return None
+
+
+def infer_task_from_ground_truth(ground_truth: Any) -> TaskFamily:
+ """Infer a builder family when the trainer provides no task metadata."""
+
+ payload_family = _infer_from_json(_json_payload(ground_truth))
+ if payload_family is not None:
+ return payload_family
+
+ text = _answer_content(ground_truth)
+ if re.fullmatch(r"[A-Ha-h]", text):
+ return TaskFamily.VIDEO_QA
+ if re.fullmatch(_NUMBER_RE, text):
+ return TaskFamily.SPATIAL_INTELLIGENCE
+ if re.search(
+ rf"{_NUMBER_RE}\s*(?:to|and|[-–—])\s*{_NUMBER_RE}",
+ text,
+ flags=re.IGNORECASE,
+ ):
+ return TaskFamily.TEMPORAL_GROUNDING
+ if re.search(
+ rf"\(\s*{_NUMBER_RE}\s*,\s*{_NUMBER_RE}\s*\)"
+ rf"\s*,\s*\(\s*{_NUMBER_RE}\s*,\s*{_NUMBER_RE}\s*\)",
+ text,
+ ):
+ return TaskFamily.SPATIAL_GROUNDING
+
+ raise UnknownTaskError(
+ "Could not infer an oracle task from ground truth. Pass task metadata "
+ "through extra, for example {'problem_type': 'tracking'}."
+ )
+
+
+_DEFAULT_ROUTER = RewardRouter()
+
+
+def _router_for(
+ settings: Optional[RouterSettings],
+ router: Optional[RewardRouter],
+) -> RewardRouter:
+ if settings is not None and router is not None:
+ raise ValueError("Pass either settings or router, not both.")
+ if router is not None:
+ return router
+ return RewardRouter(settings) if settings is not None else _DEFAULT_ROUTER
+
+
+def compute_reward(
+ sample: Mapping[str, Any],
+ response: str,
+ *,
+ settings: Optional[RouterSettings] = None,
+ router: Optional[RewardRouter] = None,
+ **kwargs: Any,
+) -> RewardResult:
+ """Compute one reward through the default or supplied router."""
+
+ return _router_for(settings, router).compute_reward(
+ sample,
+ response,
+ **kwargs,
+ )
+
+
+def compute_score(
+ batch: Sequence[Mapping[str, Any]],
+ **kwargs: Any,
+) -> List[RewardResult]:
+ """Trainer-compatible mixed-task batch entry point."""
+
+ settings = kwargs.pop("settings", None)
+ router = kwargs.pop("router", None)
+ return _router_for(settings, router).compute_score(batch, **kwargs)
+
+
+def build_oracle_response(
+ sample: Mapping[str, Any],
+ *,
+ settings: Optional[RouterSettings] = None,
+ router: Optional[RewardRouter] = None,
+) -> Optional[str]:
+ """Build one oracle response from a typed sample."""
+
+ return _router_for(settings, router).build_oracle_response(sample)
+
+
+def build_oracle_response_from_ground_truth(
+ ground_truth: Any,
+ extra: Any = None,
+ *,
+ settings: Optional[RouterSettings] = None,
+ router: Optional[RewardRouter] = None,
+) -> Optional[str]:
+ """Trainer-compatible oracle builder entry point."""
+
+ return _router_for(
+ settings,
+ router,
+ ).build_oracle_response_from_ground_truth(ground_truth, extra)
diff --git a/code/orarl/rewards/types.py b/code/orarl/rewards/types.py
new file mode 100644
index 0000000000000000000000000000000000000000..5d87e440b5f46706df218326047517b3bfc243eb
--- /dev/null
+++ b/code/orarl/rewards/types.py
@@ -0,0 +1,80 @@
+"""Typed interfaces shared by OraRL reward adapters."""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any, Dict, List, Mapping, Optional, Protocol, TypedDict
+
+
+class TaskFamily(str, Enum):
+ """The seven reward families supported by the public router."""
+
+ TEMPORAL_GROUNDING = "temporal_grounding"
+ TRACKING = "tracking"
+ SEGMENTATION = "segmentation"
+ SPATIAL_GROUNDING = "spatial_grounding"
+ SPATIAL_TEMPORAL_GROUNDING = "spatial_temporal_grounding"
+ SPATIAL_INTELLIGENCE = "spatial_intelligence"
+ VIDEO_QA = "video_qa"
+
+ def __str__(self) -> str:
+ return self.value
+
+
+class RewardSample(TypedDict, total=False):
+ """Fields consumed by the router.
+
+ Task adapters may consume additional dataset-specific fields.
+ """
+
+ task: str
+ task_name: str
+ problem_type: str
+ task_source: str
+ scoring_family: str
+ question_type: str
+ response: str
+ ground_truth: Any
+ answer: Any
+
+
+class RewardMetrics(TypedDict, total=False):
+ """Common metrics; task adapters may add numeric telemetry fields."""
+
+ overall: float
+ accuracy: float
+ format: float
+
+
+RewardBatch = List[Mapping[str, Any]]
+RewardResult = Dict[str, float]
+
+
+class RewardAdapter(Protocol):
+ """Structural contract implemented by packaged and custom adapters."""
+
+ def compute_score(
+ self,
+ batch: List[Dict[str, Any]],
+ **kwargs: Any,
+ ) -> List[Mapping[str, Any]]:
+ """Score one or more samples."""
+
+ def build_oracle_response_from_ground_truth(
+ self,
+ ground_truth: Any,
+ extra: Optional[Mapping[str, Any]] = None,
+ ) -> Optional[str]:
+ """Build the response text used for an oracle row."""
+
+
+class RewardError(Exception):
+ """Base class for reward routing failures."""
+
+
+class UnknownTaskError(RewardError, ValueError):
+ """Raised when a sample cannot be assigned to a supported task."""
+
+
+class RewardContractError(RewardError, RuntimeError):
+ """Raised when an adapter violates the unified reward contract."""
diff --git a/code/pyproject.toml b/code/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..a0cdaeb02b4ab8c1bdda2aa92239be750b68df29
--- /dev/null
+++ b/code/pyproject.toml
@@ -0,0 +1,98 @@
+[build-system]
+requires = ["setuptools>=77", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "orarl"
+version = "0.1.0"
+description = "Annotations as rollouts for unified video MLLM reinforcement learning"
+readme = "README.md"
+requires-python = ">=3.10"
+license = "Apache-2.0"
+license-files = ["LICENSE", "NOTICE"]
+keywords = [
+ "reinforcement-learning",
+ "video-understanding",
+ "multimodal",
+ "grpo",
+]
+authors = [
+ { name = "Yunheng Li" },
+ { name = "Guohong Mu" },
+ { name = "Hao Li" },
+ { name = "Shengsheng Qian" },
+ { name = "Dingwen Zhang" },
+ { name = "Qibin Hou" },
+ { name = "Ming-Ming Cheng" },
+]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+]
+dependencies = [
+ "numpy",
+ "PyYAML",
+ "torch",
+]
+
+[project.optional-dependencies]
+test = ["pytest"]
+lint = ["ruff"]
+hf = ["huggingface_hub"]
+
+[project.urls]
+Homepage = "https://orarl.github.io/"
+Documentation = "https://orarl.github.io/"
+
+[project.scripts]
+orarl-train = "orarl.cli.train:main"
+orarl-eval = "orarl.cli.evaluate:main"
+orarl-eval-data = "orarl.cli.eval_data:main"
+orarl-prepare = "orarl.cli.prepare:main"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["orarl*", "verl*"]
+
+[tool.setuptools.data-files]
+"share/orarl" = ["environment.yml", "requirements-cu129.txt"]
+"share/orarl/configs" = ["configs/*.yaml"]
+"share/orarl/docs" = ["docs/*.md"]
+"share/orarl/scripts" = ["scripts/*.py", "scripts/*.sh"]
+"share/orarl/eval" = ["eval/README.md"]
+"share/orarl/eval/task" = ["eval/task/*.py", "eval/task/*.sh"]
+"share/orarl/eval/task/mindcube" = ["eval/task/mindcube/*.py"]
+"share/orarl/eval/task/mmsi" = ["eval/task/mmsi/*.py", "eval/task/mmsi/*.sh"]
+"share/orarl/eval/task/revsi" = ["eval/task/revsi/*.py", "eval/task/revsi/*.sh"]
+"share/orarl/eval/task/segmentation" = [
+ "eval/task/segmentation/*.py",
+ "eval/task/segmentation/*.sh",
+]
+"share/orarl/eval/task/spatial_grounding" = ["eval/task/spatial_grounding/*.py"]
+"share/orarl/eval/task/spatial_temporal_grounding" = [
+ "eval/task/spatial_temporal_grounding/*.py",
+]
+"share/orarl/eval/task/temporal_grounding" = [
+ "eval/task/temporal_grounding/*.py",
+ "eval/task/temporal_grounding/*.sh",
+]
+"share/orarl/eval/task/tracking" = ["eval/task/tracking/*.py"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-ra"
+
+[tool.ruff]
+target-version = "py310"
+line-length = 100
+# The evaluation runtime, the training runtime, and the checkpoint merger are
+# ported verbatim from the stack that produced the published numbers. Several of
+# their long lines are prompt literals, so reformatting them would change model
+# inputs.
+extend-exclude = ["eval/task", "verl", "scripts/model_merger.py"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "W"]
diff --git a/code/requirements-cu129.txt b/code/requirements-cu129.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e55085c509b0fee317d0ec9eec91a4485f69a521
--- /dev/null
+++ b/code/requirements-cu129.txt
@@ -0,0 +1,28 @@
+# CUDA-sensitive packages (PyTorch, vLLM, and FlashAttention) are installed in
+# a controlled order by scripts/create_conda_env.sh.
+
+accelerate==1.13.0
+codetiming==1.4.0
+datasets==4.8.4
+liger-kernel==0.7.0
+math-verify==0.9.0
+mathruler==0.1.0
+numpy==2.2.6
+omegaconf==2.3.0
+opencv-python-headless==4.13.0.92
+pandas==3.0.2
+peft==0.19.1
+pillow==12.1.1
+pyarrow==23.0.1
+pycocotools==2.0.11
+pylatexenc==2.10
+qwen-vl-utils[decord]==0.0.14
+ray[default]==2.54.0
+rouge-score==0.1.2
+tensordict==0.12.2
+# TorchCodec follows the PyTorch minor ABI: 0.10 is required for torch 2.10.
+torchcodec==0.10.0
+torchdata==0.11.0
+transformers==5.5.4
+flash-linear-attention==0.4.2
+wandb==0.26.0
diff --git a/code/runs/smoke-training-9b-20260825_174304/smoke.log b/code/runs/smoke-training-9b-20260825_174304/smoke.log
new file mode 100644
index 0000000000000000000000000000000000000000..572849e23e215d0ddbbea096ad47301531b07ba3
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_174304/smoke.log
@@ -0,0 +1,467 @@
+OraRL root: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official
+Python: /data/miniconda3/envs/orarl/bin/python
+Model: /apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B
+Train data: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl
+Val data: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl
+Recipe scale: 9b
+World size: 1 x 8
+Steps per run: 1
+Results: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_174304
+Execute: 1
+0, NVIDIA H20, 0 MiB, 97871 MiB
+1, NVIDIA H20, 0 MiB, 97871 MiB
+2, NVIDIA H20, 0 MiB, 97871 MiB
+3, NVIDIA H20, 0 MiB, 97871 MiB
+4, NVIDIA H20, 0 MiB, 97871 MiB
+5, NVIDIA H20, 0 MiB, 97871 MiB
+6, NVIDIA H20, 0 MiB, 97871 MiB
+7, NVIDIA H20, 0 MiB, 97871 MiB
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+ warnings.warn(
+dependency OK: numpy 2.2.6
+dependency OK: torch 2.10.0+cu129
+dependency OK: transformers 5.5.4
+dependency OK: vllm 0.19.1
+dependency OK: ray 2.54.0
+dependency OK: tensordict 0.12.2
+dependency OK: codetiming 1.4.0
+dependency OK: omegaconf 2.3.0
+dependency OK: verl.trainer.main /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/main.py
+dependency OK: orarl.rewards /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/orarl/rewards/__init__.py
+
+=== grpo smoke (1 update(s)) ===
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+ warnings.warn(
+2026-08-25 17:43:40,058 INFO worker.py:2004 -- Started a local Ray instance. View the dashboard at [1m[32mhttp://127.0.0.1:8265 [39m[22m
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/worker.py:2052: FutureWarning: Tip: In future versions of Ray, Ray will no longer override accelerator visible devices env var if num_gpus=0 or num_gpus=None (default). To enable this behavior and turn off this error message, set RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO=0
+ warnings.warn(
+[36m(pid=1944439)[0m W0825 17:43:56.478000 1944439 site-packages/torch/utils/cpp_extension.py:118] No CUDA runtime is found, using CUDA_HOME='/data/miniconda3/envs/orarl'
+[36m(Runner pid=1944439)[0m {
+[36m(Runner pid=1944439)[0m "data": {
+[36m(Runner pid=1944439)[0m "train_files": "/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl",
+[36m(Runner pid=1944439)[0m "val_files": "/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl",
+[36m(Runner pid=1944439)[0m "prompt_key": "problem",
+[36m(Runner pid=1944439)[0m "answer_key": "answer",
+[36m(Runner pid=1944439)[0m "image_key": "images",
+[36m(Runner pid=1944439)[0m "video_key": "videos",
+[36m(Runner pid=1944439)[0m "image_dir": "/",
+[36m(Runner pid=1944439)[0m "video_fps": 2.0,
+[36m(Runner pid=1944439)[0m "video_max_frames": 128,
+[36m(Runner pid=1944439)[0m "max_prompt_length": 24576,
+[36m(Runner pid=1944439)[0m "max_response_length": 4096,
+[36m(Runner pid=1944439)[0m "rollout_batch_size": 8,
+[36m(Runner pid=1944439)[0m "mini_rollout_batch_size": null,
+[36m(Runner pid=1944439)[0m "val_batch_size": 16,
+[36m(Runner pid=1944439)[0m "format_prompt": null,
+[36m(Runner pid=1944439)[0m "override_chat_template": null,
+[36m(Runner pid=1944439)[0m "enable_thinking": false,
+[36m(Runner pid=1944439)[0m "response_prefix": "",
+[36m(Runner pid=1944439)[0m "shuffle": true,
+[36m(Runner pid=1944439)[0m "seed": 42,
+[36m(Runner pid=1944439)[0m "min_pixels": 4096,
+[36m(Runner pid=1944439)[0m "max_pixels": 4194304,
+[36m(Runner pid=1944439)[0m "image_min_pixels": 4096,
+[36m(Runner pid=1944439)[0m "image_max_pixels": 1048576,
+[36m(Runner pid=1944439)[0m "video_min_pixels": 4096,
+[36m(Runner pid=1944439)[0m "video_max_pixels": 786432,
+[36m(Runner pid=1944439)[0m "video_total_pixels": 8388608,
+[36m(Runner pid=1944439)[0m "val_video_fps": 2.0,
+[36m(Runner pid=1944439)[0m "val_video_max_frames": 128,
+[36m(Runner pid=1944439)[0m "val_video_min_pixels": 4096,
+[36m(Runner pid=1944439)[0m "val_video_max_pixels": 786432,
+[36m(Runner pid=1944439)[0m "val_video_total_pixels": 8388608,
+[36m(Runner pid=1944439)[0m "filter_overlong_prompts": false,
+[36m(Runner pid=1944439)[0m "filter_overlong_prompts_workers": 16,
+[36m(Runner pid=1944439)[0m "use_preprocessed_videos": false,
+[36m(Runner pid=1944439)[0m "video_source_mode": "realtime_only",
+[36m(Runner pid=1944439)[0m "preprocessed_video_dir": null,
+[36m(Runner pid=1944439)[0m "val_preprocessed_video_dir": null,
+[36m(Runner pid=1944439)[0m "val_video_source_mode": "realtime_only",
+[36m(Runner pid=1944439)[0m "inline_video_tensors": true,
+[36m(Runner pid=1944439)[0m "group_by_task": true,
+[36m(Runner pid=1944439)[0m "group_by_task_key": "problem_type",
+[36m(Runner pid=1944439)[0m "dataloader_num_workers": 1
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "worker": {
+[36m(Runner pid=1944439)[0m "hybrid_engine": true,
+[36m(Runner pid=1944439)[0m "actor": {
+[36m(Runner pid=1944439)[0m "strategy": "fsdp",
+[36m(Runner pid=1944439)[0m "global_batch_size": 8,
+[36m(Runner pid=1944439)[0m "micro_batch_size_per_device_for_update": 1,
+[36m(Runner pid=1944439)[0m "micro_batch_size_per_device_for_experience": 1,
+[36m(Runner pid=1944439)[0m "max_grad_norm": 1.0,
+[36m(Runner pid=1944439)[0m "clip_ratio_low": 0.2,
+[36m(Runner pid=1944439)[0m "clip_ratio_high": 0.2,
+[36m(Runner pid=1944439)[0m "clip_ratio_dual": 3.0,
+[36m(Runner pid=1944439)[0m "loss_avg_mode": "token",
+[36m(Runner pid=1944439)[0m "loss_type": "default",
+[36m(Runner pid=1944439)[0m "ppo_epochs": 1,
+[36m(Runner pid=1944439)[0m "padding_free": true,
+[36m(Runner pid=1944439)[0m "dynamic_batching": true,
+[36m(Runner pid=1944439)[0m "max_token_len_per_gpu": 32768,
+[36m(Runner pid=1944439)[0m "ulysses_size": 1,
+[36m(Runner pid=1944439)[0m "use_torch_compile": false,
+[36m(Runner pid=1944439)[0m "model": {
+[36m(Runner pid=1944439)[0m "model_path": "/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B",
+[36m(Runner pid=1944439)[0m "tokenizer_path": "/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B",
+[36m(Runner pid=1944439)[0m "override_config": {},
+[36m(Runner pid=1944439)[0m "enable_gradient_checkpointing": true,
+[36m(Runner pid=1944439)[0m "trust_remote_code": false,
+[36m(Runner pid=1944439)[0m "freeze_vision_tower": true,
+[36m(Runner pid=1944439)[0m "train_vision_merger": false
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "optim": {
+[36m(Runner pid=1944439)[0m "lr": 1e-06,
+[36m(Runner pid=1944439)[0m "betas": [
+[36m(Runner pid=1944439)[0m 0.9,
+[36m(Runner pid=1944439)[0m 0.999
+[36m(Runner pid=1944439)[0m ],
+[36m(Runner pid=1944439)[0m "weight_decay": 0.0,
+[36m(Runner pid=1944439)[0m "strategy": "adamw",
+[36m(Runner pid=1944439)[0m "lr_warmup_ratio": 0.0,
+[36m(Runner pid=1944439)[0m "lr_warmup_steps": null,
+[36m(Runner pid=1944439)[0m "min_lr_ratio": null,
+[36m(Runner pid=1944439)[0m "lr_scheduler_type": "constant",
+[36m(Runner pid=1944439)[0m "training_steps": -1
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "fsdp": {
+[36m(Runner pid=1944439)[0m "enable_full_shard": true,
+[36m(Runner pid=1944439)[0m "enable_cpu_offload": false,
+[36m(Runner pid=1944439)[0m "enable_rank0_init": true,
+[36m(Runner pid=1944439)[0m "use_orig_params": false,
+[36m(Runner pid=1944439)[0m "torch_dtype": "bf16",
+[36m(Runner pid=1944439)[0m "fsdp_size": -1,
+[36m(Runner pid=1944439)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=1944439)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=1944439)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "offload": {
+[36m(Runner pid=1944439)[0m "offload_params": false,
+[36m(Runner pid=1944439)[0m "offload_optimizer": false
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "global_batch_size_per_device": -1,
+[36m(Runner pid=1944439)[0m "disable_kl": true,
+[36m(Runner pid=1944439)[0m "use_kl_loss": false,
+[36m(Runner pid=1944439)[0m "kl_penalty": "low_var_kl",
+[36m(Runner pid=1944439)[0m "kl_coef": 0.0
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "critic": {
+[36m(Runner pid=1944439)[0m "strategy": "fsdp",
+[36m(Runner pid=1944439)[0m "global_batch_size": 256,
+[36m(Runner pid=1944439)[0m "micro_batch_size_per_device_for_update": 4,
+[36m(Runner pid=1944439)[0m "micro_batch_size_per_device_for_experience": 16,
+[36m(Runner pid=1944439)[0m "max_grad_norm": 1.0,
+[36m(Runner pid=1944439)[0m "cliprange_value": 0.5,
+[36m(Runner pid=1944439)[0m "loss_avg_mode": "token",
+[36m(Runner pid=1944439)[0m "ppo_epochs": 1,
+[36m(Runner pid=1944439)[0m "padding_free": false,
+[36m(Runner pid=1944439)[0m "dynamic_batching": true,
+[36m(Runner pid=1944439)[0m "max_token_len_per_gpu": null,
+[36m(Runner pid=1944439)[0m "ulysses_size": 1,
+[36m(Runner pid=1944439)[0m "model": {
+[36m(Runner pid=1944439)[0m "model_path": null,
+[36m(Runner pid=1944439)[0m "tokenizer_path": null,
+[36m(Runner pid=1944439)[0m "override_config": {},
+[36m(Runner pid=1944439)[0m "enable_gradient_checkpointing": true,
+[36m(Runner pid=1944439)[0m "trust_remote_code": true,
+[36m(Runner pid=1944439)[0m "freeze_vision_tower": false,
+[36m(Runner pid=1944439)[0m "train_vision_merger": false
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "optim": {
+[36m(Runner pid=1944439)[0m "lr": 1e-06,
+[36m(Runner pid=1944439)[0m "betas": [
+[36m(Runner pid=1944439)[0m 0.9,[36m(Runner pid=1944439)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.
+[36m(Runner pid=1944439)[0m
Generating train split: 0 examples [00:00, ? examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 2684 examples [00:00, 5741.64 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 3894 examples [00:00, 5566.39 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 6050 examples [00:01, 5916.34 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 7310 examples [00:01, 5917.78 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 8652 examples [00:01, 7062.55 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 10017 examples [00:01, 5373.29 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 11061 examples [00:01, 6122.95 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 12345 examples [00:01, 6764.91 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 13564 examples [00:02, 4332.70 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 14792 examples [00:02, 5251.29 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 15958 examples [00:02, 5219.09 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 17208 examples [00:03, 5223.77 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 18484 examples [00:03, 6319.60 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 19610 examples [00:03, 5646.33 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 20953 examples [00:03, 6782.24 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 22170 examples [00:03, 7115.81 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 23412 examples [00:03, 6220.19 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 24542 examples [00:04, 7068.41 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 25620 examples [00:04, 5887.92 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 27027 examples [00:05, 2384.25 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 29107 examples [00:06, 2709.31 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 30179 examples [00:06, 2852.90 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 32832 examples [00:06, 4368.47 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 35008 examples [00:06, 5689.84 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 38278 examples [00:07, 7779.78 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 41012 examples [00:07, 9843.69 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 43744 examples [00:07, 10491.50 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 46312 examples [00:07, 12026.91 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 49391 examples [00:08, 8630.42 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 52225 examples [00:08, 10551.99 examples/s]
+[36m(Runner pid=1944439)[0m
Generating train split: 52225 examples [00:08, 6076.51 examples/s]
+Traceback (most recent call last):
+ File "", line 198, in _run_module_as_main
+ File "", line 88, in _run_code
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/main.py", line 137, in
+ main()
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/main.py", line 129, in main
+ ray.get(runner.run.remote(ppo_config))
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/auto_init_hook.py", line 22, in auto_init_wrapper
+ return fn(*args, **kwargs)
+ ^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/client_mode_hook.py", line 104, in wrapper
+ return func(*args, **kwargs)
+ ^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/worker.py", line 2981, in get
+ values, debugger_breakpoint = worker.get_objects(
+ ^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/worker.py", line 1012, in get_objects
+ raise value.as_instanceof_cause()
+ray.exceptions.RayTaskError(DatasetGenerationError): [36mray::Runner.run()[39m (pid=1944439, ip=29.127.69.4, actor_id=e981ae3ef4ecbc78705cfeea01000000, repr=)
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/datasets/packaged_modules/json/json.py", line 265, in _generate_tables
+ self._cast_table(pa_table, json_field_paths=json_field_paths),
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/datasets/packaged_modules/json/json.py", line 120, in _cast_table
+ pa_table = table_cast(pa_table, self.info.features.arrow_schema)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/datasets/table.py", line 2272, in table_cast
+ return cast_table_to_schema(table, schema)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/datasets/table.py", line 2218, in cast_table_to_schema
+ raise CastError(
+datasets.table.CastError: Couldn't cast
+problem: string
+answer: string
+ground_truth: string
+problem_type: string
+videos: list
+ child 0, item: string
+data_type: string
+task_source: string
+segmentation_output: string
+meta: string
+images: list
+ child 0, item: string
+fps: int64
+video_second: double
+resolution: struct
+ child 0, width: int64
+ child 1, height: int64
+path: string
+data_source: string
+messages: list>
+ child 0, item: struct
+ child 0, role: string
+ child 1, content: string
+solution: string
+difficulty: double
+pred_iou: double
+pred_span: list
+ child 0, item: double
+pred_text: string
+checkpoint_score: double
+hardness_band: string
+selection_role: string
+selection_checkpoint: string
+identity_key: string
+media_anchors: list
+ child 0, item: string
+selection_temporal_bucket: string
+question_type: string
+source_file: string
+source_id: string
+selection_weak_buckets: list
+ child 0, item: string
+selection_bucket: string
+source_block: string
+selection_scores: struct)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/main.py", line 74, in run
+ train_dataloader, val_dataloader = create_dataloader(
+ ^^^^^^^^^^^^^^^^^^
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/data_loader.py", line 41, in create_dataloader
+ train_dataset = RLHFDataset(
+ ^^^^^^^^^^^^
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/utils/dataset.py", line 444, in __init__
+ self.dataset = load_dataset(file_type, data_files=data_path, split=data_split)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/datasets/load.py", line 1708, in load_dataset
+ builder_instance.download_and_prepare(
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/datasets/builder.py", line 884, in download_and_prepare
+ self._download_and_prepare(
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/datasets/builder.py", line 947, in _download_and_prepare
+ self._prepare_split(split_generator, **prepare_split_kwargs)
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/datasets/builder.py", line 1739, in _prepare_split
+ for job_id, done, content in self._prepare_split_single(
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/datasets/builder.py", line 1922, in _prepare_split_single
+ raise DatasetGenerationError("An error occurred while generating the dataset") from e
+datasets.exceptions.DatasetGenerationError: An error occurred while generating the dataset
+
+[36m(Runner pid=1944439)[0m 0.999
+[36m(Runner pid=1944439)[0m ],
+[36m(Runner pid=1944439)[0m "weight_decay": 0.01,
+[36m(Runner pid=1944439)[0m "strategy": "adamw",
+[36m(Runner pid=1944439)[0m "lr_warmup_ratio": 0.0,
+[36m(Runner pid=1944439)[0m "lr_warmup_steps": null,
+[36m(Runner pid=1944439)[0m "min_lr_ratio": null,
+[36m(Runner pid=1944439)[0m "lr_scheduler_type": "constant",
+[36m(Runner pid=1944439)[0m "training_steps": -1
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "fsdp": {
+[36m(Runner pid=1944439)[0m "enable_full_shard": true,
+[36m(Runner pid=1944439)[0m "enable_cpu_offload": false,
+[36m(Runner pid=1944439)[0m "enable_rank0_init": true,
+[36m(Runner pid=1944439)[0m "use_orig_params": false,
+[36m(Runner pid=1944439)[0m "torch_dtype": null,
+[36m(Runner pid=1944439)[0m "fsdp_size": -1,
+[36m(Runner pid=1944439)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=1944439)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=1944439)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "offload": {
+[36m(Runner pid=1944439)[0m "offload_params": false,
+[36m(Runner pid=1944439)[0m "offload_optimizer": false
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "global_batch_size_per_device": -1
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "ref": {
+[36m(Runner pid=1944439)[0m "strategy": "fsdp",
+[36m(Runner pid=1944439)[0m "fsdp": {
+[36m(Runner pid=1944439)[0m "enable_full_shard": true,
+[36m(Runner pid=1944439)[0m "enable_cpu_offload": false,
+[36m(Runner pid=1944439)[0m "enable_rank0_init": true,
+[36m(Runner pid=1944439)[0m "use_orig_params": false,
+[36m(Runner pid=1944439)[0m "torch_dtype": "bf16",
+[36m(Runner pid=1944439)[0m "fsdp_size": -1,
+[36m(Runner pid=1944439)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=1944439)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=1944439)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "offload": {
+[36m(Runner pid=1944439)[0m "offload_params": false,
+[36m(Runner pid=1944439)[0m "offload_optimizer": false
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "micro_batch_size_per_device_for_experience": 1,
+[36m(Runner pid=1944439)[0m "padding_free": true,
+[36m(Runner pid=1944439)[0m "dynamic_batching": true,
+[36m(Runner pid=1944439)[0m "max_token_len_per_gpu": 32768,
+[36m(Runner pid=1944439)[0m "ulysses_size": 1,
+[36m(Runner pid=1944439)[0m "use_torch_compile": false
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "reward": {
+[36m(Runner pid=1944439)[0m "reward_function": "orarl.rewards",
+[36m(Runner pid=1944439)[0m "reward_function_kwargs": {},
+[36m(Runner pid=1944439)[0m "skip_special_tokens": true,
+[36m(Runner pid=1944439)[0m "num_cpus": 1,
+[36m(Runner pid=1944439)[0m "reward_function_name": "compute_score",
+[36m(Runner pid=1944439)[0m "reward_function_is_module": true
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "rollout": {
+[36m(Runner pid=1944439)[0m "name": "vllm",
+[36m(Runner pid=1944439)[0m "n": 8,
+[36m(Runner pid=1944439)[0m "temperature": 1.0,
+[36m(Runner pid=1944439)[0m "top_p": 0.85,
+[36m(Runner pid=1944439)[0m "top_k": -1,
+[36m(Runner pid=1944439)[0m "seed": 42,
+[36m(Runner pid=1944439)[0m "limit_images": 10,
+[36m(Runner pid=1944439)[0m "dtype": "bf16",
+[36m(Runner pid=1944439)[0m "gpu_memory_utilization": 0.45,
+[36m(Runner pid=1944439)[0m "ignore_eos": false,
+[36m(Runner pid=1944439)[0m "enforce_eager": false,
+[36m(Runner pid=1944439)[0m "enable_chunked_prefill": true,
+[36m(Runner pid=1944439)[0m "tensor_parallel_size": 1,
+[36m(Runner pid=1944439)[0m "max_model_len": null,
+[36m(Runner pid=1944439)[0m "max_num_batched_tokens": 32768,
+[36m(Runner pid=1944439)[0m "disable_log_stats": true,
+[36m(Runner pid=1944439)[0m "disable_tqdm": true,
+[36m(Runner pid=1944439)[0m "val_override_config": {
+[36m(Runner pid=1944439)[0m "temperature": 0.7,
+[36m(Runner pid=1944439)[0m "top_p": 0.95,
+[36m(Runner pid=1944439)[0m "n": 1
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "kv_cache_dtype": "auto",
+[36m(Runner pid=1944439)[0m "calculate_log_probs": false,
+[36m(Runner pid=1944439)[0m "collect_seq_logprob_for_filter": false,
+[36m(Runner pid=1944439)[0m "prompt_length": 24576,
+[36m(Runner pid=1944439)[0m "response_length": 4096,
+[36m(Runner pid=1944439)[0m "trust_remote_code": false
+[36m(Runner pid=1944439)[0m }
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "algorithm": {
+[36m(Runner pid=1944439)[0m "name": "grpo",
+[36m(Runner pid=1944439)[0m "gamma": 1.0,
+[36m(Runner pid=1944439)[0m "lam": 1.0,
+[36m(Runner pid=1944439)[0m "adv_estimator": "grpo",
+[36m(Runner pid=1944439)[0m "scale_rewards": true,
+[36m(Runner pid=1944439)[0m "disable_kl": true,
+[36m(Runner pid=1944439)[0m "use_kl_loss": false,
+[36m(Runner pid=1944439)[0m "kl_penalty": "low_var_kl",
+[36m(Runner pid=1944439)[0m "kl_coef": 0.0,
+[36m(Runner pid=1944439)[0m "kl_type": "fixed",
+[36m(Runner pid=1944439)[0m "kl_horizon": 10000.0,
+[36m(Runner pid=1944439)[0m "kl_target": 0.1
+[36m(Runner pid=1944439)[0m },
+[36m(Runner pid=1944439)[0m "trainer": {
+[36m(Runner pid=1944439)[0m "total_epochs": 1,
+[36m(Runner pid=1944439)[0m "max_steps": 1,
+[36m(Runner pid=1944439)[0m "project_name": "OraRL",
+[36m(Runner pid=1944439)[0m "experiment_name": "smoke-grpo-9b",
+[36m(Runner pid=1944439)[0m "logger": [
+[36m(Runner pid=1944439)[0m "console"
+[36m(Runner pid=1944439)[0m ],
+[36m(Runner pid=1944439)[0m "nnodes": 1,
+[36m(Runner pid=1944439)[0m "n_gpus_per_node": 8,
+[36m(Runner pid=1944439)[0m "critic_warmup": 0,
+[36m(Runner pid=1944439)[0m "val_freq": -1,
+[36m(Runner pid=1944439)[0m "val_before_train": false,
+[36m(Runner pid=1944439)[0m "val_only": false,
+[36m(Runner pid=1944439)[0m "val_generations_to_log": 0,
+[36m(Runner pid=1944439)[0m "save_freq": 1,
+[36m(Runner pid=1944439)[0m "save_limit": 3,
+[36m(Runner pid=1944439)[0m "save_model_only": false,
+[36m(Runner pid=1944439)[0m "keep_optim_only_latest": true,
+[36m(Runner pid=1944439)[0m "save_checkpoint_path": "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_174304/grpo",
+[36m(Runner pid=1944439)[0m "load_checkpoint_path": null,
+[36m(Runner pid=1944439)[0m "ray_timeline": null,
+[36m(Runner pid=1944439)[0m "find_last_checkpoint": false,
+[36m(Runner pid=1944439)[0m "keep_best_train_ckpt": false,
+[36m(Runner pid=1944439)[0m "best_train_metric_key": "reward/overall",
+[36m(Runner pid=1944439)[0m "best_train_smooth_window": 5,
+[36m(Runner pid=1944439)[0m "best_train_min_step": 10
+[36m(Runner pid=1944439)[0m }
+[36m(Runner pid=1944439)[0m }
+[36m(AutoRewardManager pid=1946036)[0m Using reward function `compute_score` from `orarl.rewards`.
+[36m(AutoRewardManager pid=1946036)[0m Reward name: orarl, reward type: batch.
+[36m(AutoRewardManager pid=1946038)[0m Using reward function `compute_score` from `orarl.rewards`.
+[36m(AutoRewardManager pid=1946038)[0m Reward name: orarl, reward type: batch.
+method=grpo mode=run
+/data/miniconda3/envs/orarl/bin/python -m verl.trainer.main config=/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/configs/grpo_9b.yaml trainer.max_steps=1 trainer.save_freq=1 trainer.val_before_train=false trainer.experiment_name=smoke-grpo-9b data.rollout_batch_size=8 worker.actor.global_batch_size=8 worker.actor.model.model_path=/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B worker.actor.model.tokenizer_path=/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B data.train_files=/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl data.val_files=/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl trainer.save_checkpoint_path=/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_174304/grpo trainer.nnodes=1 trainer.n_gpus_per_node=8
diff --git a/code/runs/smoke-training-9b-20260825_175213/smoke.log b/code/runs/smoke-training-9b-20260825_175213/smoke.log
new file mode 100644
index 0000000000000000000000000000000000000000..ec43ca43443fafe8272b4cd9c86b41fc634ccb26
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175213/smoke.log
@@ -0,0 +1,827 @@
+OraRL root: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official
+Python: /data/miniconda3/envs/orarl/bin/python
+Model: /apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B
+Train data: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl
+Val data: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl
+Recipe scale: 9b
+World size: 1 x 8
+Steps per run: 1
+Results: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175213
+Execute: 1
+0, NVIDIA H20, 0 MiB, 97871 MiB
+1, NVIDIA H20, 0 MiB, 97871 MiB
+2, NVIDIA H20, 0 MiB, 97871 MiB
+3, NVIDIA H20, 0 MiB, 97871 MiB
+4, NVIDIA H20, 0 MiB, 97871 MiB
+5, NVIDIA H20, 0 MiB, 97871 MiB
+6, NVIDIA H20, 0 MiB, 97871 MiB
+7, NVIDIA H20, 0 MiB, 97871 MiB
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+ warnings.warn(
+dependency OK: numpy 2.2.6
+dependency OK: torch 2.10.0+cu129
+dependency OK: transformers 5.5.4
+dependency OK: vllm 0.19.1
+dependency OK: ray 2.54.0
+dependency OK: tensordict 0.12.2
+dependency OK: codetiming 1.4.0
+dependency OK: omegaconf 2.3.0
+dependency OK: verl.trainer.main /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/main.py
+dependency OK: orarl.rewards /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/orarl/rewards/__init__.py
+
+=== grpo smoke (1 update(s)) ===
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+ warnings.warn(
+2026-08-25 17:52:41,284 INFO worker.py:2004 -- Started a local Ray instance. View the dashboard at [1m[32mhttp://127.0.0.1:8265 [39m[22m
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/worker.py:2052: FutureWarning: Tip: In future versions of Ray, Ray will no longer override accelerator visible devices env var if num_gpus=0 or num_gpus=None (default). To enable this behavior and turn off this error message, set RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO=0
+ warnings.warn(
+[36m(pid=1984436)[0m W0825 17:52:57.110000 1984436 site-packages/torch/utils/cpp_extension.py:118] No CUDA runtime is found, using CUDA_HOME='/data/miniconda3/envs/orarl'
+[36m(Runner pid=1984436)[0m {
+[36m(Runner pid=1984436)[0m "data": {
+[36m(Runner pid=1984436)[0m "train_files": "/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl",
+[36m(Runner pid=1984436)[0m "val_files": "/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl",
+[36m(Runner pid=1984436)[0m "prompt_key": "problem",
+[36m(Runner pid=1984436)[0m "answer_key": "answer",
+[36m(Runner pid=1984436)[0m "image_key": "images",
+[36m(Runner pid=1984436)[0m "video_key": "videos",
+[36m(Runner pid=1984436)[0m "image_dir": "/",
+[36m(Runner pid=1984436)[0m "video_fps": 2.0,
+[36m(Runner pid=1984436)[0m "video_max_frames": 128,
+[36m(Runner pid=1984436)[0m "max_prompt_length": 24576,
+[36m(Runner pid=1984436)[0m "max_response_length": 4096,
+[36m(Runner pid=1984436)[0m "rollout_batch_size": 8,
+[36m(Runner pid=1984436)[0m "mini_rollout_batch_size": null,
+[36m(Runner pid=1984436)[0m "val_batch_size": 16,
+[36m(Runner pid=1984436)[0m "format_prompt": null,
+[36m(Runner pid=1984436)[0m "override_chat_template": null,
+[36m(Runner pid=1984436)[0m "enable_thinking": false,
+[36m(Runner pid=1984436)[0m "response_prefix": "",
+[36m(Runner pid=1984436)[0m "shuffle": true,
+[36m(Runner pid=1984436)[0m "seed": 42,
+[36m(Runner pid=1984436)[0m "min_pixels": 4096,
+[36m(Runner pid=1984436)[0m "max_pixels": 4194304,
+[36m(Runner pid=1984436)[0m "image_min_pixels": 4096,
+[36m(Runner pid=1984436)[0m "image_max_pixels": 1048576,
+[36m(Runner pid=1984436)[0m "video_min_pixels": 4096,
+[36m(Runner pid=1984436)[0m "video_max_pixels": 786432,
+[36m(Runner pid=1984436)[0m "video_total_pixels": 8388608,
+[36m(Runner pid=1984436)[0m "val_video_fps": 2.0,
+[36m(Runner pid=1984436)[0m "val_video_max_frames": 128,
+[36m(Runner pid=1984436)[0m "val_video_min_pixels": 4096,
+[36m(Runner pid=1984436)[0m "val_video_max_pixels": 786432,
+[36m(Runner pid=1984436)[0m "val_video_total_pixels": 8388608,
+[36m(Runner pid=1984436)[0m "filter_overlong_prompts": false,
+[36m(Runner pid=1984436)[0m "filter_overlong_prompts_workers": 16,
+[36m(Runner pid=1984436)[0m "use_preprocessed_videos": false,
+[36m(Runner pid=1984436)[0m "video_source_mode": "realtime_only",
+[36m(Runner pid=1984436)[0m "preprocessed_video_dir": null,
+[36m(Runner pid=1984436)[0m "val_preprocessed_video_dir": null,
+[36m(Runner pid=1984436)[0m "val_video_source_mode": "realtime_only",
+[36m(Runner pid=1984436)[0m "inline_video_tensors": true,
+[36m(Runner pid=1984436)[0m "group_by_task": true,
+[36m(Runner pid=1984436)[0m "group_by_task_key": "problem_type",
+[36m(Runner pid=1984436)[0m "dataloader_num_workers": 1
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "worker": {
+[36m(Runner pid=1984436)[0m "hybrid_engine": true,
+[36m(Runner pid=1984436)[0m "actor": {
+[36m(Runner pid=1984436)[0m "strategy": "fsdp",
+[36m(Runner pid=1984436)[0m "global_batch_size": 8,
+[36m(Runner pid=1984436)[0m "micro_batch_size_per_device_for_update": 1,
+[36m(Runner pid=1984436)[0m "micro_batch_size_per_device_for_experience": 1,
+[36m(Runner pid=1984436)[0m "max_grad_norm": 1.0,
+[36m(Runner pid=1984436)[0m "clip_ratio_low": 0.2,
+[36m(Runner pid=1984436)[0m "clip_ratio_high": 0.2,
+[36m(Runner pid=1984436)[0m "clip_ratio_dual": 3.0,
+[36m(Runner pid=1984436)[0m "loss_avg_mode": "token",
+[36m(Runner pid=1984436)[0m "loss_type": "default",
+[36m(Runner pid=1984436)[0m "ppo_epochs": 1,
+[36m(Runner pid=1984436)[0m "padding_free": true,
+[36m(Runner pid=1984436)[0m "dynamic_batching": true,
+[36m(Runner pid=1984436)[0m "max_token_len_per_gpu": 32768,
+[36m(Runner pid=1984436)[0m "ulysses_size": 1,
+[36m(Runner pid=1984436)[0m "use_torch_compile": false,
+[36m(Runner pid=1984436)[0m "model": {
+[36m(Runner pid=1984436)[0m "model_path": "/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B",
+[36m(Runner pid=1984436)[0m "tokenizer_path": "/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B",
+[36m(Runner pid=1984436)[0m "override_config": {},
+[36m(Runner pid=1984436)[0m "enable_gradient_checkpointing": true,
+[36m(Runner pid=1984436)[0m "trust_remote_code": false,
+[36m(Runner pid=1984436)[0m "freeze_vision_tower": true,
+[36m(Runner pid=1984436)[0m "train_vision_merger": false
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "optim": {
+[36m(Runner pid=1984436)[0m "lr": 1e-06,
+[36m(Runner pid=1984436)[0m "betas": [
+[36m(Runner pid=1984436)[0m 0.9,
+[36m(Runner pid=1984436)[0m 0.999
+[36m(Runner pid=1984436)[0m ],
+[36m(Runner pid=1984436)[0m "weight_decay": 0.0,
+[36m(Runner pid=1984436)[0m "strategy": "adamw",
+[36m(Runner pid=1984436)[0m "lr_warmup_ratio": 0.0,
+[36m(Runner pid=1984436)[0m "lr_warmup_steps": null,
+[36m(Runner pid=1984436)[0m "min_lr_ratio": null,
+[36m(Runner pid=1984436)[0m "lr_scheduler_type": "constant",
+[36m(Runner pid=1984436)[0m "training_steps": -1
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "fsdp": {
+[36m(Runner pid=1984436)[0m "enable_full_shard": true,
+[36m(Runner pid=1984436)[0m "enable_cpu_offload": false,
+[36m(Runner pid=1984436)[0m "enable_rank0_init": true,
+[36m(Runner pid=1984436)[0m "use_orig_params": false,
+[36m(Runner pid=1984436)[0m "torch_dtype": "bf16",
+[36m(Runner pid=1984436)[0m "fsdp_size": -1,
+[36m(Runner pid=1984436)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=1984436)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=1984436)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "offload": {
+[36m(Runner pid=1984436)[0m "offload_params": false,
+[36m(Runner pid=1984436)[0m "offload_optimizer": false
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "global_batch_size_per_device": -1,
+[36m(Runner pid=1984436)[0m "disable_kl": true,
+[36m(Runner pid=1984436)[0m "use_kl_loss": false,
+[36m(Runner pid=1984436)[0m "kl_penalty": "low_var_kl",
+[36m(Runner pid=1984436)[0m "kl_coef": 0.0
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "critic": {
+[36m(Runner pid=1984436)[0m "strategy": "fsdp",
+[36m(Runner pid=1984436)[0m "global_batch_size": 256,
+[36m(Runner pid=1984436)[0m "micro_batch_size_per_device_for_update": 4,
+[36m(Runner pid=1984436)[0m "micro_batch_size_per_device_for_experience": 16,
+[36m(Runner pid=1984436)[0m "max_grad_norm": 1.0,
+[36m(Runner pid=1984436)[0m "cliprange_value": 0.5,
+[36m(Runner pid=1984436)[0m "loss_avg_mode": "token",
+[36m(Runner pid=1984436)[0m "ppo_epochs": 1,
+[36m(Runner pid=1984436)[0m "padding_free": false,
+[36m(Runner pid=1984436)[0m "dynamic_batching": true,
+[36m(Runner pid=1984436)[0m "max_token_len_per_gpu": null,
+[36m(Runner pid=1984436)[0m "ulysses_size": 1,
+[36m(Runner pid=1984436)[0m "model": {
+[36m(Runner pid=1984436)[0m "model_path": null,
+[36m(Runner pid=1984436)[0m "tokenizer_path": null,
+[36m(Runner pid=1984436)[0m "override_config": {},
+[36m(Runner pid=1984436)[0m "enable_gradient_checkpointing": true,
+[36m(Runner pid=1984436)[0m "trust_remote_code": true,
+[36m(Runner pid=1984436)[0m "freeze_vision_tower": false,
+[36m(Runner pid=1984436)[0m "train_vision_merger": false
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "optim": {
+[36m(Runner pid=1984436)[0m "lr": 1e-06,
+[36m(Runner pid=1984436)[0m "betas": [
+[36m(Runner pid=1984436)[0m 0.9,[36m(Runner pid=1984436)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.
+
+[36m(Runner pid=1984436)[0m 0.999
+[36m(Runner pid=1984436)[0m ],
+[36m(Runner pid=1984436)[0m "weight_decay": 0.01,
+[36m(Runner pid=1984436)[0m "strategy": "adamw",
+[36m(Runner pid=1984436)[0m "lr_warmup_ratio": 0.0,
+[36m(Runner pid=1984436)[0m "lr_warmup_steps": null,
+[36m(Runner pid=1984436)[0m "min_lr_ratio": null,
+[36m(Runner pid=1984436)[0m "lr_scheduler_type": "constant",
+[36m(Runner pid=1984436)[0m "training_steps": -1
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "fsdp": {
+[36m(Runner pid=1984436)[0m "enable_full_shard": true,
+[36m(Runner pid=1984436)[0m "enable_cpu_offload": false,
+[36m(Runner pid=1984436)[0m "enable_rank0_init": true,
+[36m(Runner pid=1984436)[0m "use_orig_params": false,
+[36m(Runner pid=1984436)[0m "torch_dtype": null,
+[36m(Runner pid=1984436)[0m "fsdp_size": -1,
+[36m(Runner pid=1984436)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=1984436)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=1984436)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "offload": {
+[36m(Runner pid=1984436)[0m "offload_params": false,
+[36m(Runner pid=1984436)[0m "offload_optimizer": false
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "global_batch_size_per_device": -1
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "ref": {
+[36m(Runner pid=1984436)[0m "strategy": "fsdp",
+[36m(Runner pid=1984436)[0m "fsdp": {
+[36m(Runner pid=1984436)[0m "enable_full_shard": true,
+[36m(Runner pid=1984436)[0m "enable_cpu_offload": false,
+[36m(Runner pid=1984436)[0m "enable_rank0_init": true,
+[36m(Runner pid=1984436)[0m "use_orig_params": false,
+[36m(Runner pid=1984436)[0m "torch_dtype": "bf16",
+[36m(Runner pid=1984436)[0m "fsdp_size": -1,
+[36m(Runner pid=1984436)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=1984436)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=1984436)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "offload": {
+[36m(Runner pid=1984436)[0m "offload_params": false,
+[36m(Runner pid=1984436)[0m "offload_optimizer": false
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "micro_batch_size_per_device_for_experience": 1,
+[36m(Runner pid=1984436)[0m "padding_free": true,
+[36m(Runner pid=1984436)[0m "dynamic_batching": true,
+[36m(Runner pid=1984436)[0m "max_token_len_per_gpu": 32768,
+[36m(Runner pid=1984436)[0m "ulysses_size": 1,
+[36m(Runner pid=1984436)[0m "use_torch_compile": false
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "reward": {
+[36m(Runner pid=1984436)[0m "reward_function": "orarl.rewards",
+[36m(Runner pid=1984436)[0m "reward_function_kwargs": {},
+[36m(Runner pid=1984436)[0m "skip_special_tokens": true,
+[36m(Runner pid=1984436)[0m "num_cpus": 1,
+[36m(Runner pid=1984436)[0m "reward_function_name": "compute_score",
+[36m(Runner pid=1984436)[0m "reward_function_is_module": true
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "rollout": {
+[36m(Runner pid=1984436)[0m "name": "vllm",
+[36m(Runner pid=1984436)[0m "n": 8,
+[36m(Runner pid=1984436)[0m "temperature": 1.0,
+[36m(Runner pid=1984436)[0m "top_p": 0.85,
+[36m(Runner pid=1984436)[0m "top_k": -1,
+[36m(Runner pid=1984436)[0m "seed": 42,
+[36m(Runner pid=1984436)[0m "limit_images": 10,
+[36m(Runner pid=1984436)[0m "dtype": "bf16",
+[36m(Runner pid=1984436)[0m "gpu_memory_utilization": 0.45,
+[36m(Runner pid=1984436)[0m "ignore_eos": false,
+[36m(Runner pid=1984436)[0m "enforce_eager": false,
+[36m(Runner pid=1984436)[0m "enable_chunked_prefill": true,
+[36m(Runner pid=1984436)[0m "tensor_parallel_size": 1,
+[36m(Runner pid=1984436)[0m "max_model_len": null,
+[36m(Runner pid=1984436)[0m "max_num_batched_tokens": 32768,
+[36m(Runner pid=1984436)[0m "disable_log_stats": true,
+[36m(Runner pid=1984436)[0m "disable_tqdm": true,
+[36m(Runner pid=1984436)[0m "val_override_config": {
+[36m(Runner pid=1984436)[0m "temperature": 0.7,
+[36m(Runner pid=1984436)[0m "top_p": 0.95,
+[36m(Runner pid=1984436)[0m "n": 1
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "kv_cache_dtype": "auto",
+[36m(Runner pid=1984436)[0m "calculate_log_probs": false,
+[36m(Runner pid=1984436)[0m "collect_seq_logprob_for_filter": false,
+[36m(Runner pid=1984436)[0m "prompt_length": 24576,
+[36m(Runner pid=1984436)[0m "response_length": 4096,
+[36m(Runner pid=1984436)[0m "trust_remote_code": false
+[36m(Runner pid=1984436)[0m }
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "algorithm": {
+[36m(Runner pid=1984436)[0m "name": "grpo",
+[36m(Runner pid=1984436)[0m "gamma": 1.0,
+[36m(Runner pid=1984436)[0m "lam": 1.0,
+[36m(Runner pid=1984436)[0m "adv_estimator": "grpo",
+[36m(Runner pid=1984436)[0m "scale_rewards": true,
+[36m(Runner pid=1984436)[0m "disable_kl": true,
+[36m(Runner pid=1984436)[0m "use_kl_loss": false,
+[36m(Runner pid=1984436)[0m "kl_penalty": "low_var_kl",
+[36m(Runner pid=1984436)[0m "kl_coef": 0.0,
+[36m(Runner pid=1984436)[0m "kl_type": "fixed",
+[36m(Runner pid=1984436)[0m "kl_horizon": 10000.0,
+[36m(Runner pid=1984436)[0m "kl_target": 0.1
+[36m(Runner pid=1984436)[0m },
+[36m(Runner pid=1984436)[0m "trainer": {
+[36m(Runner pid=1984436)[0m "total_epochs": 1,
+[36m(Runner pid=1984436)[0m "max_steps": 1,
+[36m(Runner pid=1984436)[0m "project_name": "OraRL",
+[36m(Runner pid=1984436)[0m "experiment_name": "smoke-grpo-9b",
+[36m(Runner pid=1984436)[0m "logger": [
+[36m(Runner pid=1984436)[0m "console"
+[36m(Runner pid=1984436)[0m ],
+[36m(Runner pid=1984436)[0m "nnodes": 1,
+[36m(Runner pid=1984436)[0m "n_gpus_per_node": 8,
+[36m(Runner pid=1984436)[0m "critic_warmup": 0,
+[36m(Runner pid=1984436)[0m "val_freq": -1,
+[36m(Runner pid=1984436)[0m "val_before_train": false,
+[36m(Runner pid=1984436)[0m "val_only": false,
+[36m(Runner pid=1984436)[0m "val_generations_to_log": 0,
+[36m(Runner pid=1984436)[0m "save_freq": 1,
+[36m(Runner pid=1984436)[0m "save_limit": 3,
+[36m(Runner pid=1984436)[0m "save_model_only": false,
+[36m(Runner pid=1984436)[0m "keep_optim_only_latest": true,
+[36m(Runner pid=1984436)[0m "save_checkpoint_path": "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175213/grpo",
+[36m(Runner pid=1984436)[0m "load_checkpoint_path": null,
+[36m(Runner pid=1984436)[0m "ray_timeline": null,
+[36m(Runner pid=1984436)[0m "find_last_checkpoint": false,
+[36m(Runner pid=1984436)[0m "keep_best_train_ckpt": false,
+[36m(Runner pid=1984436)[0m "best_train_metric_key": "reward/overall",
+[36m(Runner pid=1984436)[0m "best_train_smooth_window": 5,
+[36m(Runner pid=1984436)[0m "best_train_min_step": 10
+[36m(Runner pid=1984436)[0m }
+[36m(Runner pid=1984436)[0m }
+[36m(Runner pid=1984436)[0m [TaskGroupedBatchSampler] 15 groups: image_sequence_mc_answer_only(10304), obj_appearance_order(64), object_abs_distance(1344), object_counting(960), object_rel_direction(448), object_rel_distance(640), object_size_estimation(1408), room_size_estimation(960), route_planning(960), segmentation(12032), spatial grounding(7040), spatial-temporal grounding(9536), temporal grounding(20096), tracking(13952), video_qa_mc(20288) | batch_size=8 | total_batches=12504
+[36m(Runner pid=1984436)[0m Size of train dataloader: 12504
+[36m(Runner pid=1984436)[0m Size of val dataloader: 32
+[36m(Runner pid=1984436)[0m KL is disabled, no KL metrics will be logged. Please set `kl_coef=0` to log KL metrics.[36m(pid=1986239)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+[36m(pid=1986239)[0m warnings.warn(
+[36m(pid=1986747)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+[36m(pid=1986747)[0m warnings.warn(
+[36m(pid=1986750)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+[36m(pid=1986750)[0m warnings.warn(
+[36m(WorkerDict pid=1986750)[0m [W825 17:53:20.598420573 ProcessGroupNCCL.cpp:929] Warning: TORCH_NCCL_AVOID_RECORD_STREAMS is the default now, this environment variable is thus deprecated. (function operator())
+[36m(pid=1986745)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.[32m [repeated 5x across cluster] (Ray deduplicates logs by default. Set RAY_DEDUP_LOGS=0 to disable log deduplication, or see https://docs.ray.io/en/master/ray-observability/user-guides/configure-logging.html#log-deduplication for more options.)[0m
+[36m(pid=1986745)[0m warnings.warn([32m [repeated 5x across cluster][0m
+[36m(WorkerDict pid=1986750)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.
+[36m(WorkerDict pid=1986750)[0m `torch_dtype` is deprecated! Use `dtype` instead!
+[36m(WorkerDict pid=1986745)[0m [W825 17:53:22.611308342 ProcessGroupNCCL.cpp:929] Warning: TORCH_NCCL_AVOID_RECORD_STREAMS is the default now, this environment variable is thus deprecated. (function operator())[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=1986750)[0m The fast path is not available because one of the required library is not installed. Falling back to torch implementation. To install follow https://github.com/fla-org/flash-linear-attention#installation and https://github.com/Dao-AILab/causal-conv1d
+[36m(WorkerDict pid=1986750)[0m [rank7]:[W825 17:53:25.252368567 ProcessGroupNCCL.cpp:5138] Guessing device ID based on global rank. This can cause a hang if rank to GPU mapping is heterogeneous. You can specify device_id in init_process_group()
+[36m(WorkerDict pid=1986239)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=1986239)[0m
Loading weights: 0%| | 0/760 [00:00, ?it/s]
+[36m(WorkerDict pid=1986239)[0m
Loading weights: 100%|██████████| 760/760 [00:00<00:00, 9091.24it/s]
+[36m(WorkerDict pid=1986239)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/distributed/c10d_logger.py:83: UserWarning: barrier(): using the device under current context. You can specify `device_id` in `init_process_group` to mute this warning.
+[36m(WorkerDict pid=1986239)[0m return func(*args, **kwargs)
+[36m(WorkerDict pid=1986750)[0m `Qwen2VLImageProcessorFast` is deprecated. The `Fast` suffix for image processors has been removed; use `Qwen2VLImageProcessor` instead.
+[36m(WorkerDict pid=1986748)[0m `torch_dtype` is deprecated! Use `dtype` instead![32m [repeated 6x across cluster][0m
+[36m(WorkerDict pid=1986748)[0m The fast path is not available because one of the required library is not installed. Falling back to torch implementation. To install follow https://github.com/fla-org/flash-linear-attention#installation and https://github.com/Dao-AILab/causal-conv1d[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=1986239)[0m [rank0]:[W825 17:53:28.954233606 ProcessGroupNCCL.cpp:5138] Guessing device ID based on global rank. This can cause a hang if rank to GPU mapping is heterogeneous. You can specify device_id in init_process_group()[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=1986747)[0m :1241: FutureWarning: The cuda.cudart module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.runtime module instead.
+[36m(WorkerDict pid=1986747)[0m :1241: FutureWarning: The cuda.nvrtc module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.nvrtc module instead.
+[36m(WorkerDict pid=1986749)[0m `Qwen2VLImageProcessorFast` is deprecated. The `Fast` suffix for image processors has been removed; use `Qwen2VLImageProcessor` instead.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=1986239)[0m 2026-08-25 17:54:33,940 - INFO - autotuner.py:262 - flashinfer.jit: [Autotuner]: Autotuning process starts ...
+[36m(WorkerDict pid=1986239)[0m 2026-08-25 17:54:33,958 - INFO - autotuner.py:268 - flashinfer.jit: [Autotuner]: Autotuning process ends
+[36m(WorkerDict pid=1986748)[0m :1241: FutureWarning: The cuda.cudart module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.runtime module instead.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=1986748)[0m :1241: FutureWarning: The cuda.nvrtc module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.nvrtc module instead.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 0%| | 0/51 [00:00, ?it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 2%|▏ | 1/51 [00:00<00:05, 9.91it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 6%|▌ | 3/51 [00:00<00:03, 12.20it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 10%|▉ | 5/51 [00:00<00:03, 13.14it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 14%|█▎ | 7/51 [00:00<00:03, 13.55it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 18%|█▊ | 9/51 [00:00<00:03, 13.17it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 22%|██▏ | 11/51 [00:00<00:02, 13.83it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 25%|██▌ | 13/51 [00:00<00:02, 14.58it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 29%|██▉ | 15/51 [00:01<00:02, 15.15it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 33%|███▎ | 17/51 [00:01<00:02, 16.11it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 39%|███▉ | 20/51 [00:01<00:01, 17.88it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 45%|████▌ | 23/51 [00:01<00:01, 18.51it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 49%|████▉ | 25/51 [00:01<00:01, 18.81it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 55%|█████▍ | 28/51 [00:01<00:01, 19.76it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 61%|██████ | 31/51 [00:01<00:00, 20.73it/s]
+[36m(WorkerDict pid=1986750)[0m 2026-08-25 17:54:33,938 - INFO - autotuner.py:262 - flashinfer.jit: [Autotuner]: Autotuning process starts ...[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=1986750)[0m 2026-08-25 17:54:33,956 - INFO - autotuner.py:268 - flashinfer.jit: [Autotuner]: Autotuning process ends[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 67%|██████▋ | 34/51 [00:01<00:00, 21.99it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 73%|███████▎ | 37/51 [00:02<00:00, 22.86it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 78%|███████▊ | 40/51 [00:02<00:00, 22.41it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 84%|████████▍ | 43/51 [00:02<00:00, 23.82it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 90%|█████████ | 46/51 [00:02<00:00, 24.27it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 96%|█████████▌| 49/51 [00:02<00:00, 25.66it/s]
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 100%|██████████| 51/51 [00:02<00:00, 19.63it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 0%| | 0/51 [00:00, ?it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 2%|▏ | 1/51 [00:00<00:10, 4.74it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 6%|▌ | 3/51 [00:00<00:05, 8.45it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 10%|▉ | 5/51 [00:00<00:04, 10.01it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 14%|█▎ | 7/51 [00:00<00:03, 11.04it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 18%|█▊ | 9/51 [00:00<00:03, 11.91it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 22%|██▏ | 11/51 [00:00<00:03, 12.70it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 25%|██▌ | 13/51 [00:01<00:03, 12.13it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 29%|██▉ | 15/51 [00:01<00:02, 12.99it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 33%|███▎ | 17/51 [00:01<00:02, 14.07it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 37%|███▋ | 19/51 [00:01<00:02, 11.67it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 41%|████ | 21/51 [00:01<00:02, 13.29it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 45%|████▌ | 23/51 [00:01<00:01, 14.62it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 49%|████▉ | 25/51 [00:01<00:01, 15.81it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 55%|█████▍ | 28/51 [00:02<00:01, 18.07it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 61%|██████ | 31/51 [00:02<00:00, 20.16it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 67%|██████▋ | 34/51 [00:02<00:00, 21.76it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 73%|███████▎ | 37/51 [00:02<00:00, 19.94it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 80%|████████ | 41/51 [00:02<00:00, 23.51it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 88%|████████▊ | 45/51 [00:02<00:00, 26.54it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 96%|█████████▌| 49/51 [00:02<00:00, 28.89it/s]
+[36m(WorkerDict pid=1986239)[0m
Capturing CUDA graphs (decode, FULL): 100%|██████████| 51/51 [00:03<00:00, 16.62it/s]
+
+[36m(Runner pid=1984436)[0m [GRPO] Scaled advantages ON: A=(r-mean)/(std+eps).
+[36m(Runner pid=1984436)[0m Total training steps: 1
+[36m(AutoRewardManager pid=1986029)[0m Using reward function `compute_score` from `orarl.rewards`.
+[36m(AutoRewardManager pid=1986029)[0m Reward name: orarl, reward type: batch.
+[36m(WorkerDict pid=1986239)[0m actor will use global batch size 64.
+[36m(AutoRewardManager pid=1986031)[0m Using reward function `compute_score` from `orarl.rewards`.
+[36m(AutoRewardManager pid=1986031)[0m Reward name: orarl, reward type: batch.
+[36m(WorkerDict pid=1986239)[0m Model config: Qwen3_5Config {
+[36m(WorkerDict pid=1986239)[0m "architectures": [
+[36m(WorkerDict pid=1986239)[0m "Qwen3_5ForConditionalGeneration"
+[36m(WorkerDict pid=1986239)[0m ],
+[36m(WorkerDict pid=1986239)[0m "dtype": "bfloat16",
+[36m(WorkerDict pid=1986239)[0m "image_token_id": 248056,
+[36m(WorkerDict pid=1986239)[0m "model_type": "qwen3_5",
+[36m(WorkerDict pid=1986239)[0m "text_config": {
+[36m(WorkerDict pid=1986239)[0m "attention_bias": false,
+[36m(WorkerDict pid=1986239)[0m "attention_dropout": 0.0,
+[36m(WorkerDict pid=1986239)[0m "attn_output_gate": true,
+[36m(WorkerDict pid=1986239)[0m "bos_token_id": null,
+[36m(WorkerDict pid=1986239)[0m "dtype": "bfloat16",
+[36m(WorkerDict pid=1986239)[0m "eos_token_id": 248044,
+[36m(WorkerDict pid=1986239)[0m "full_attention_interval": 4,
+[36m(WorkerDict pid=1986239)[0m "head_dim": 256,
+[36m(WorkerDict pid=1986239)[0m "hidden_act": "silu",
+[36m(WorkerDict pid=1986239)[0m "hidden_size": 4096,
+[36m(WorkerDict pid=1986239)[0m "initializer_range": 0.02,
+[36m(WorkerDict pid=1986239)[0m "intermediate_size": 12288,
+[36m(WorkerDict pid=1986239)[0m "layer_types": [
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "full_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "full_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "full_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "full_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "full_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "full_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "full_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "linear_attention",
+[36m(WorkerDict pid=1986239)[0m "full_attention"
+[36m(WorkerDict pid=1986239)[0m ],
+[36m(WorkerDict pid=1986239)[0m "linear_conv_kernel_dim": 4,
+[36m(WorkerDict pid=1986239)[0m "linear_key_head_dim": 128,
+[36m(WorkerDict pid=1986239)[0m "linear_num_key_heads": 16,
+[36m(WorkerDict pid=1986239)[0m "linear_num_value_heads": 32,
+[36m(WorkerDict pid=1986239)[0m "linear_value_head_dim": 128,
+[36m(WorkerDict pid=1986239)[0m "mamba_ssm_dtype": "float32",
+[36m(WorkerDict pid=1986239)[0m "max_position_embeddings": 262144,
+[36m(WorkerDict pid=1986239)[0m "mlp_only_layers": [],
+[36m(WorkerDict pid=1986239)[0m "model_type": "qwen3_5_text",
+[36m(WorkerDict pid=1986239)[0m "mtp_num_hidden_layers": 0,
+[36m(WorkerDict pid=1986239)[0m "mtp_use_dedicated_embeddings": false,
+[36m(WorkerDict pid=1986239)[0m "num_attention_heads": 16,
+[36m(WorkerDict pid=1986239)[0m "num_hidden_layers": 32,
+[36m(WorkerDict pid=1986239)[0m "num_key_value_heads": 4,
+[36m(WorkerDict pid=1986239)[0m "pad_token_id": null,
+[36m(WorkerDict pid=1986239)[0m "partial_rotary_factor": 0.25,
+[36m(WorkerDict pid=1986239)[0m "rms_norm_eps": 1e-06,
+[36m(WorkerDict pid=1986239)[0m "rope_parameters": {
+[36m(WorkerDict pid=1986239)[0m "mrope_interleaved": true,
+[36m(WorkerDict pid=1986239)[0m "mrope_section": [
+[36m(WorkerDict pid=1986239)[0m 11,
+[36m(WorkerDict pid=1986239)[0m 11,
+[36m(WorkerDict pid=1986239)[0m 10
+[36m(WorkerDict pid=1986239)[0m ],
+[36m(WorkerDict pid=1986239)[0m "partial_rotary_factor": 0.25,
+[36m(WorkerDict pid=1986239)[0m "rope_theta": 10000000,
+[36m(WorkerDict pid=1986239)[0m "rope_type": "default"
+[36m(WorkerDict pid=1986239)[0m },
+[36m(WorkerDict pid=1986239)[0m "tie_word_embeddings": false,
+[36m(WorkerDict pid=1986239)[0m "use_cache": true,
+[36m(WorkerDict pid=1986239)[0m "vocab_size": 248320
+[36m(WorkerDict pid=1986239)[0m },
+[36m(WorkerDict pid=1986239)[0m "tie_word_embeddings": false,
+[36m(WorkerDict pid=1986239)[0m "transformers_version": "5.5.4",
+[36m(WorkerDict pid=1986239)[0m "video_token_id": 248057,
+[36m(WorkerDict pid=1986239)[0m "vision_config": {
+[36m(WorkerDict pid=1986239)[0m "deepstack_visual_indexes": [],
+[36m(WorkerDict pid=1986239)[0m "depth": 27,
+[36m(WorkerDict pid=1986239)[0m "dtype": "bfloat16",
+[36m(WorkerDict pid=1986239)[0m "hidden_act": "gelu_pytorch_tanh",
+[36m(WorkerDict pid=1986239)[0m "hidden_size": 1152,
+[36m(WorkerDict pid=1986239)[0m "in_channels": 3,
+[36m(WorkerDict pid=1986239)[0m "initializer_range": 0.02,
+[36m(WorkerDict pid=1986239)[0m "intermediate_size": 4304,
+[36m(WorkerDict pid=1986239)[0m "model_type": "qwen3_5",
+[36m(WorkerDict pid=1986239)[0m "num_heads": 16,
+[36m(WorkerDict pid=1986239)[0m "num_position_embeddings": 2304,
+[36m(WorkerDict pid=1986239)[0m "out_hidden_size": 4096,
+[36m(WorkerDict pid=1986239)[0m "patch_size": 16,
+[36m(WorkerDict pid=1986239)[0m "spatial_merge_size": 2,
+[36m(WorkerDict pid=1986239)[0m "temporal_patch_size": 2
+[36m(WorkerDict pid=1986239)[0m },
+[36m(WorkerDict pid=1986239)[0m "vision_end_token_id": 248054,
+[36m(WorkerDict pid=1986239)[0m "vision_start_token_id": 248053
+[36m(WorkerDict pid=1986239)[0m }
+[36m(WorkerDict pid=1986239)[0m
+[36m(WorkerDict pid=1986239)[0m Ulysses patch applied!
+[36m(WorkerDict pid=1986239)[0m Vision backbone is set to not trainable.
+[36m(WorkerDict pid=1986239)[0m Qwen3_5ForConditionalGeneration contains 9.41B parameters.
+[36m(WorkerDict pid=1986239)[0m After huggingface model init: 1.17 GB / 95.00 GB.
+[36m(WorkerDict pid=1986239)[0m FSDP wrap policy: functools.partial(, transformer_layer_cls={, }).
+[36m(WorkerDict pid=1986239)[0m After FSDP module init: 15.42 GB / 95.00 GB.
+[36m(WorkerDict pid=1986239)[0m After optimizer init: 15.42 GB / 95.00 GB.
+[36m(WorkerDict pid=1986744)[0m Sampling params: {'max_tokens': 4096, 'detokenize': False, 'logit_bias': {248056: -100, 248057: -100}, 'n': 8, 'temperature': 1.0, 'top_p': 0.85, 'top_k': -1, 'ignore_eos': False}.
+[36m(WorkerDict pid=1986239)[0m After vllm init: 7.76 GB / 95.00 GB.
+[36m(Runner pid=1984436)[0m Config
+[36m(Runner pid=1984436)[0m algorithm:
+[36m(Runner pid=1984436)[0m adv_estimator: grpo
+[36m(Runner pid=1984436)[0m disable_kl: true
+[36m(Runner pid=1984436)[0m gamma: 1.0
+[36m(Runner pid=1984436)[0m kl_coef: 0.0
+[36m(Runner pid=1984436)[0m kl_horizon: 10000.0
+[36m(Runner pid=1984436)[0m kl_penalty: low_var_kl
+[36m(Runner pid=1984436)[0m kl_target: 0.1
+[36m(Runner pid=1984436)[0m kl_type: fixed
+[36m(Runner pid=1984436)[0m lam: 1.0
+[36m(Runner pid=1984436)[0m name: grpo
+[36m(Runner pid=1984436)[0m scale_rewards: true
+[36m(Runner pid=1984436)[0m use_kl_loss: false
+[36m(Runner pid=1984436)[0m data:
+[36m(Runner pid=1984436)[0m answer_key: answer
+[36m(Runner pid=1984436)[0m dataloader_num_workers: 1
+[36m(Runner pid=1984436)[0m enable_thinking: false
+[36m(Runner pid=1984436)[0m filter_overlong_prompts: false
+[36m(Runner pid=1984436)[0m filter_overlong_prompts_workers: 16
+[36m(Runner pid=1984436)[0m format_prompt: null
+[36m(Runner pid=1984436)[0m group_by_task: true
+[36m(Runner pid=1984436)[0m group_by_task_key: problem_type
+[36m(Runner pid=1984436)[0m image_dir: /
+[36m(Runner pid=1984436)[0m image_key: images
+[36m(Runner pid=1984436)[0m image_max_pixels: 1048576
+[36m(Runner pid=1984436)[0m image_min_pixels: 4096
+[36m(Runner pid=1984436)[0m inline_video_tensors: true
+[36m(Runner pid=1984436)[0m max_pixels: 4194304
+[36m(Runner pid=1984436)[0m max_prompt_length: 24576
+[36m(Runner pid=1984436)[0m max_response_length: 4096
+[36m(Runner pid=1984436)[0m min_pixels: 4096
+[36m(Runner pid=1984436)[0m mini_rollout_batch_size: null
+[36m(Runner pid=1984436)[0m override_chat_template: null
+[36m(Runner pid=1984436)[0m preprocessed_video_dir: null
+[36m(Runner pid=1984436)[0m prompt_key: problem
+[36m(Runner pid=1984436)[0m response_prefix: ''
+[36m(Runner pid=1984436)[0m rollout_batch_size: 8
+[36m(Runner pid=1984436)[0m seed: 42
+[36m(Runner pid=1984436)[0m shuffle: true
+[36m(Runner pid=1984436)[0m train_files: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl
+[36m(Runner pid=1984436)[0m use_preprocessed_videos: false
+[36m(Runner pid=1984436)[0m val_batch_size: 16
+[36m(Runner pid=1984436)[0m val_files: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl
+[36m(Runner pid=1984436)[0m val_preprocessed_video_dir: null
+[36m(Runner pid=1984436)[0m val_video_fps: 2.0
+[36m(Runner pid=1984436)[0m val_video_max_frames: 128
+[36m(Runner pid=1984436)[0m val_video_max_pixels: 786432
+[36m(Runner pid=1984436)[0m val_video_min_pixels: 4096
+[36m(Runner pid=1984436)[0m val_video_source_mode: realtime_only
+[36m(Runner pid=1984436)[0m val_video_total_pixels: 8388608
+[36m(Runner pid=1984436)[0m video_fps: 2.0
+[36m(Runner pid=1984436)[0m video_key: videos
+[36m(Runner pid=1984436)[0m video_max_frames: 128
+[36m(Runner pid=1984436)[0m video_max_pixels: 786432
+[36m(Runner pid=1984436)[0m video_min_pixels: 4096
+[36m(Runner pid=1984436)[0m video_source_mode: realtime_only
+[36m(Runner pid=1984436)[0m video_total_pixels: 8388608
+[36m(Runner pid=1984436)[0m trainer:
+[36m(Runner pid=1984436)[0m best_train_metric_key: reward/overall
+[36m(Runner pid=1984436)[0m best_train_min_step: 10
+[36m(Runner pid=1984436)[0m best_train_smooth_window: 5
+[36m(Runner pid=1984436)[0m critic_warmup: 0
+[36m(Runner pid=1984436)[0m experiment_name: smoke-grpo-9b
+[36m(Runner pid=1984436)[0m find_last_checkpoint: false
+[36m(Runner pid=1984436)[0m keep_best_train_ckpt: false
+[36m(Runner pid=1984436)[0m keep_optim_only_latest: true
+[36m(Runner pid=1984436)[0m load_checkpoint_path: null
+[36m(Runner pid=1984436)[0m logger:
+[36m(Runner pid=1984436)[0m - console
+[36m(Runner pid=1984436)[0m max_steps: 1
+[36m(Runner pid=1984436)[0m n_gpus_per_node: 8
+[36m(Runner pid=1984436)[0m nnodes: 1
+[36m(Runner pid=1984436)[0m project_name: OraRL
+[36m(Runner pid=1984436)[0m ray_timeline: null
+[36m(Runner pid=1984436)[0m save_checkpoint_path: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175213/grpo
+[36m(Runner pid=1984436)[0m save_freq: 1
+[36m(Runner pid=1984436)[0m save_limit: 3
+[36m(Runner pid=1984436)[0m save_model_only: false
+[36m(Runner pid=1984436)[0m total_epochs: 1
+[36m(Runner pid=1984436)[0m val_before_train: false
+[36m(Runner pid=1984436)[0m val_freq: -1
+[36m(Runner pid=1984436)[0m val_generations_to_log: 0
+[36m(Runner pid=1984436)[0m val_only: false
+[36m(Runner pid=1984436)[0m worker:
+[36m(Runner pid=1984436)[0m actor:
+[36m(Runner pid=1984436)[0m clip_ratio_dual: 3.0
+[36m(Runner pid=1984436)[0m clip_ratio_high: 0.2
+[36m(Runner pid=1984436)[0m clip_ratio_low: 0.2
+[36m(Runner pid=1984436)[0m disable_kl: true
+[36m(Runner pid=1984436)[0m dynamic_batching: true
+[36m(Runner pid=1984436)[0m fsdp:
+[36m(Runner pid=1984436)[0m enable_cpu_offload: false
+[36m(Runner pid=1984436)[0m enable_full_shard: true
+[36m(Runner pid=1984436)[0m enable_rank0_init: true
+[36m(Runner pid=1984436)[0m fsdp_size: -1
+[36m(Runner pid=1984436)[0m mp_buffer_dtype: fp32
+[36m(Runner pid=1984436)[0m mp_param_dtype: bf16
+[36m(Runner pid=1984436)[0m mp_reduce_dtype: fp32
+[36m(Runner pid=1984436)[0m torch_dtype: bf16
+[36m(Runner pid=1984436)[0m use_orig_params: false
+[36m(Runner pid=1984436)[0m global_batch_size: 8
+[36m(Runner pid=1984436)[0m global_batch_size_per_device: -1
+[36m(Runner pid=1984436)[0m kl_coef: 0.0
+[36m(Runner pid=1984436)[0m kl_penalty: low_var_kl
+[36m(Runner pid=1984436)[0m loss_avg_mode: token
+[36m(Runner pid=1984436)[0m loss_type: default
+[36m(Runner pid=1984436)[0m max_grad_norm: 1.0
+[36m(Runner pid=1984436)[0m max_token_len_per_gpu: 32768
+[36m(Runner pid=1984436)[0m micro_batch_size_per_device_for_experience: 1
+[36m(Runner pid=1984436)[0m micro_batch_size_per_device_for_update: 1
+[36m(Runner pid=1984436)[0m model:
+[36m(Runner pid=1984436)[0m enable_gradient_checkpointing: true
+[36m(Runner pid=1984436)[0m freeze_vision_tower: true
+[36m(Runner pid=1984436)[0m model_path: /apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B
+[36m(Runner pid=1984436)[0m override_config: {}
+[36m(Runner pid=1984436)[0m tokenizer_path: /apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B
+[36m(Runner pid=1984436)[0m train_vision_merger: false
+[36m(Runner pid=1984436)[0m trust_remote_code: false
+[36m(Runner pid=1984436)[0m offload:
+[36m(Runner pid=1984436)[0m offload_optimizer: false
+[36m(Runner pid=1984436)[0m offload_params: false
+[36m(Runner pid=1984436)[0m optim:
+[36m(Runner pid=1984436)[0m betas:
+[36m(Runner pid=1984436)[0m - 0.9
+[36m(Runner pid=1984436)[0m - 0.999
+[36m(Runner pid=1984436)[0m lr: 1.0e-06
+[36m(Runner pid=1984436)[0m lr_scheduler_type: constant
+[36m(Runner pid=1984436)[0m lr_warmup_ratio: 0.0
+[36m(Runner pid=1984436)[0m lr_warmup_steps: null
+[36m(Runner pid=1984436)[0m min_lr_ratio: null
+[36m(Runner pid=1984436)[0m strategy: adamw
+[36m(Runner pid=1984436)[0m training_steps: 1
+[36m(Runner pid=1984436)[0m weight_decay: 0.0
+[36m(Runner pid=1984436)[0m padding_free: true
+[36m(Runner pid=1984436)[0m ppo_epochs: 1
+[36m(Runner pid=1984436)[0m strategy: fsdp
+[36m(Runner pid=1984436)[0m ulysses_size: 1
+[36m(Runner pid=1984436)[0m use_kl_loss: false
+[36m(Runner pid=1984436)[0m use_torch_compile: false
+[36m(Runner pid=1984436)[0m critic:
+[36m(Runner pid=1984436)[0m cliprange_value: 0.5
+[36m(Runner pid=1984436)[0m dynamic_batching: true
+[36m(Runner pid=1984436)[0m fsdp:
+[36m(Runner pid=1984436)[0m enable_cpu_offload: false
+[36m(Runner pid=1984436)[0m enable_full_shard: true
+[36m(Runner pid=1984436)[0m enable_rank0_init: true
+[36m(Runner pid=1984436)[0m fsdp_size: -1
+[36m(Runner pid=1984436)[0m mp_buffer_dtype: fp32
+[36m(Runner pid=1984436)[0m mp_param_dtype: bf16
+[36m(Runner pid=1984436)[0m mp_reduce_dtype: fp32
+[36m(Runner pid=1984436)[0m torch_dtype: null
+[36m(Runner pid=1984436)[0m use_orig_params: false
+[36m(Runner pid=1984436)[0m global_batch_size: 256
+[36m(Runner pid=1984436)[0m global_batch_size_per_device: -1
+[36m(Runner pid=1984436)[0m loss_avg_mode: token
+[36m(Runner pid=1984436)[0m max_grad_norm: 1.0
+[36m(Runner pid=1984436)[0m max_token_len_per_gpu: null
+[36m(Runner pid=1984436)[0m micro_batch_size_per_device_for_experience: 16
+[36m(Runner pid=1984436)[0m micro_batch_size_per_device_for_update: 4
+[36m(Runner pid=1984436)[0m model:
+[36m(Runner pid=1984436)[0m enable_gradient_checkpointing: true
+[36m(Runner pid=1984436)[0m freeze_vision_tower: false
+[36m(Runner pid=1984436)[0m model_path: null
+[36m(Runner pid=1984436)[0m override_config: {}
+[36m(Runner pid=1984436)[0m tokenizer_path: null
+[36m(Runner pid=1984436)[0m train_vision_merger: false
+[36m(Runner pid=1984436)[0m trust_remote_code: true
+[36m(Runner pid=1984436)[0m offload:
+[36m(Runner pid=1984436)[0m offload_optimizer: false
+[36m(Runner pid=1984436)[0m offload_params: false
+[36m(Runner pid=1984436)[0m optim:
+[36m(Runner pid=1984436)[0m betas:
+[36m(Runner pid=1984436)[0m - 0.9
+[36m(Runner pid=1984436)[0m - 0.999
+[36m(Runner pid=1984436)[0m lr: 1.0e-06
+[36m(Runner pid=1984436)[0m lr_scheduler_type: constant
+[36m(Runner pid=1984436)[0m lr_warmup_ratio: 0.0
+[36m(Runner pid=1984436)[0m lr_warmup_steps: null
+[36m(Runner pid=1984436)[0m min_lr_ratio: null
+[36m(Runner pid=1984436)[0m strategy: adamw
+[36m(Runner pid=1984436)[0m training_steps: 1
+[36m(Runner pid=1984436)[0m weight_decay: 0.01
+[36m(Runner pid=1984436)[0m padding_free: false
+[36m(Runner pid=1984436)[0m ppo_epochs: 1
+[36m(Runner pid=1984436)[0m strategy: fsdp
+[36m(Runner pid=1984436)[0m ulysses_size: 1
+[36m(Runner pid=1984436)[0m hybrid_engine: true
+[36m(Runner pid=1984436)[0m ref:
+[36m(Runner pid=1984436)[0m dynamic_batching: true
+[36m(Runner pid=1984436)[0m fsdp:
+[36m(Runner pid=1984436)[0m enable_cpu_offload: false
+[36m(Runner pid=1984436)[0m enable_full_shard: true
+[36m(Runner pid=1984436)[0m enable_rank0_init: true
+[36m(Runner pid=1984436)[0m fsdp_size: -1
+[36m(Runner pid=1984436)[0m mp_buffer_dtype: fp32
+[36m(Runner pid=1984436)[0m mp_param_dtype: bf16
+[36m(Runner pid=1984436)[0m mp_reduce_dtype: fp32
+[36m(Runner pid=1984436)[0m torch_dtype: bf16
+[36m(Runner pid=1984436)[0m use_orig_params: false
+[36m(Runner pid=1984436)[0m max_token_len_per_gpu: 32768
+[36m(Runner pid=1984436)[0m micro_batch_size_per_device_for_experience: 1
+[36m(Runner pid=1984436)[0m offload:
+[36m(Runner pid=1984436)[0m offload_optimizer: false
+[36m(Runner pid=1984436)[0m offload_params: false
+[36m(Runner pid=1984436)[0m padding_free: true
+[36m(Runner pid=1984436)[0m strategy: fsdp
+[36m(Runner pid=1984436)[0m ulysses_size: 1
+[36m(Runner pid=1984436)[0m use_torch_compile: false
+[36m(Runner pid=1984436)[0m reward:
+[36m(Runner pid=1984436)[0m num_cpus: 1
+[36m(Runner pid=1984436)[0m reward_function: orarl.rewards
+[36m(Runner pid=1984436)[0m reward_function_is_module: true
+[36m(Runner pid=1984436)[0m reward_function_kwargs: {}
+[36m(Runner pid=1984436)[0m reward_function_name: compute_score
+[36m(Runner pid=1984436)[0m skip_special_tokens: true
+[36m(Runner pid=1984436)[0m rollout:
+[36m(Runner pid=1984436)[0m calculate_log_probs: false
+[36m(Runner pid=1984436)[0m collect_seq_logprob_for_filter: false
+[36m(Runner pid=1984436)[0m disable_log_stats: true
+[36m(Runner pid=1984436)[0m disable_tqdm: true
+[36m(Runner pid=1984436)[0m dtype: bf16
+[36m(Runner pid=1984436)[0m enable_chunked_prefill: true
+[36m(Runner pid=1984436)[0m enforce_eager: false
+[36m(Runner pid=1984436)[0m gpu_memory_utilization: 0.45
+[36m(Runner pid=1984436)[0m ignore_eos: false
+[36m(Runner pid=1984436)[0m kv_cache_dtype: auto
+[36m(Runner pid=1984436)[0m limit_images: 10
+[36m(Runner pid=1984436)[0m max_model_len: null
+[36m(Runner pid=1984436)[0m max_num_batched_tokens: 32768
+[36m(Runner pid=1984436)[0m n: 8
+[36m(Runner pid=1984436)[0m name: vllm
+[36m(Runner pid=1984436)[0m prompt_length: 24576
+[36m(Runner pid=1984436)[0m response_length: 4096
+[36m(Runner pid=1984436)[0m seed: 42
+[36m(Runner pid=1984436)[0m temperature: 1.0
+[36m(Runner pid=1984436)[0m tensor_parallel_size: 1
+[36m(Runner pid=1984436)[0m top_k: -1
+[36m(Runner pid=1984436)[0m top_p: 0.85
+[36m(Runner pid=1984436)[0m trust_remote_code: false
+[36m(Runner pid=1984436)[0m val_override_config:
+[36m(Runner pid=1984436)[0m n: 1
+[36m(Runner pid=1984436)[0m temperature: 0.7
+[36m(Runner pid=1984436)[0m top_p: 0.95
+[36m(Runner pid=1984436)[0m
+
[2m[36m(pid=1984436) [0mRunning step: 0%| | 0.00/1.00 [00:00, ?it/s]
[2m[36m(pid=1984436) [0mRunning step: 0%| | 0.00/1.00 [00:00, ?it/s]
[2m[36m(pid=1984436) [0mRunning step: 0%| | 0.00/1.00 [00:00, ?it/s]
[2m[36m(pid=1984436) [0mRunning step: 0%| | 0.00/1.00 [00:01, ?it/s]
[2m[36m(pid=1984436) [0mRunning step: 0%| | 0.00/1.00 [00:01, ?it/s]
[2m[36m(pid=1984436) [0mRunning step: 0%| | 0.00/1.00 [00:01, ?it/s]Traceback (most recent call last):
+ File "", line 198, in _run_module_as_main
+ File "", line 88, in _run_code
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/main.py", line 137, in
+ main()
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/main.py", line 129, in main
+ ray.get(runner.run.remote(ppo_config))
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/auto_init_hook.py", line 22, in auto_init_wrapper
+ return fn(*args, **kwargs)
+ ^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/client_mode_hook.py", line 104, in wrapper
+ return func(*args, **kwargs)
+ ^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/worker.py", line 2981, in get
+ values, debugger_breakpoint = worker.get_objects(
+ ^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/worker.py", line 1012, in get_objects
+ raise value.as_instanceof_cause()
+ray.exceptions.RayTaskError(IndexError): [36mray::Runner.run()[39m (pid=1984436, ip=29.127.69.4, actor_id=2700fe7dc5d33e51d56c429c01000000, repr=)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/main.py", line 92, in run
+ trainer.fit()
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/ray_trainer.py", line 1870, in fit
+ batch = self._make_batch_data(metrics=metrics)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/ray_trainer.py", line 1662, in _make_batch_data
+ batch_dict = next(self.data_iterator)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/torchdata/stateful_dataloader/stateful_dataloader.py", line 450, in __next__
+ return super().__next__()
+ ^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/utils/data/dataloader.py", line 741, in __next__
+ data = self._next_data()
+ ^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/torchdata/stateful_dataloader/stateful_dataloader.py", line 1456, in _next_data
+ return self._process_data(data, worker_id, state_dict)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/torchdata/stateful_dataloader/stateful_dataloader.py", line 1543, in _process_data
+ data.reraise()
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/_utils.py", line 775, in reraise
+ raise exception
+IndexError: Caught IndexError in DataLoader worker process 0.
+Original Traceback (most recent call last):
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/torchdata/stateful_dataloader/worker.py", line 242, in _worker_loop
+ data = fetcher.fetch(index) # type: ignore[union-attr]
+ ^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/utils/data/_utils/fetch.py", line 54, in fetch
+ data = [self.dataset[idx] for idx in possibly_batched_index]
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/utils/data/_utils/fetch.py", line 54, in
+ data = [self.dataset[idx] for idx in possibly_batched_index]
+ ~~~~~~~~~~~~^^^^^
+ File "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/utils/dataset.py", line 826, in __getitem__
+ model_inputs = self.processor(processed_images, [prompt], add_special_tokens=False, return_tensors="pt")
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/models/qwen3_vl/processing_qwen3_vl.py", line 132, in __call__
+ num_image_tokens = image_grid_thw[index].prod() // merge_length
+ ~~~~~~~~~~~~~~^^^^^^^
+IndexError: index 8 is out of bounds for dimension 0 with size 8
+
[36m(Runner pid=1984436)[0m qwen-vl-utils using decord to read video.
+
[2m[36m(pid=1984436) [0mRunning step: 0%| | 0.00/1.00 [00:02, ?it/s]
[2m[36m(pid=1984436) [0mRunning step: 0%| | 0.00/1.00 [00:02, ?it/s][36m(WorkerDict pid=1986239)[0m Before vllm wake up in sharding manager: 7.76 GB / 95.00 GB.
+[36m(WorkerDict pid=1986239)[0m After gather model weights in sharding manager: 31.26 GB / 95.00 GB.
+[36m(Runner pid=1984436)[0m Start generating batch...
+[36m(WorkerDict pid=1986239)[0m After sync model weights in sharding manager: 25.44 GB / 95.00 GB.
+[36m(WorkerDict pid=1986239)[0m After vllm wake up in sharding manager: 45.79 GB / 95.00 GB.
+[36m(WorkerDict pid=1986746)[0m Sampling params: {'max_tokens': 4096, 'detokenize': False, 'logit_bias': {248056: -100, 248057: -100}, 'n': 8, 'temperature': 1.0, 'top_p': 0.85, 'top_k': -1, 'ignore_eos': False}.[32m [repeated 7x across cluster][0m
+
[2m[36m(pid=1984436) [0mRunning step: 0%| | 0.00/1.00 [00:06, ?it/s]
+method=grpo mode=run
+/data/miniconda3/envs/orarl/bin/python -m verl.trainer.main config=/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/configs/grpo_9b.yaml trainer.max_steps=1 trainer.save_freq=1 trainer.val_before_train=false trainer.experiment_name=smoke-grpo-9b data.rollout_batch_size=8 worker.actor.global_batch_size=8 worker.actor.model.model_path=/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B worker.actor.model.tokenizer_path=/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B data.train_files=/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl data.val_files=/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl trainer.save_checkpoint_path=/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175213/grpo trainer.nnodes=1 trainer.n_gpus_per_node=8
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/checkpoint_tracker.json b/code/runs/smoke-training-9b-20260825_175949/grpo/checkpoint_tracker.json
new file mode 100644
index 0000000000000000000000000000000000000000..0fcf0afbb9e157734d78d06eda5f8cf105fc7736
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/checkpoint_tracker.json
@@ -0,0 +1,6 @@
+{
+ "best_global_step": null,
+ "best_val_reward_score": -1.0,
+ "last_global_step": 1,
+ "last_actor_path": "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor"
+}
\ No newline at end of file
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_0.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_0.pt
new file mode 100644
index 0000000000000000000000000000000000000000..9ddf9fa959f9f7e04210a74d1c92d33701d6b33e
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_0.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_1.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_1.pt
new file mode 100644
index 0000000000000000000000000000000000000000..ff1c6d6f862719840b74d28237a295340dabc090
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_1.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_2.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_2.pt
new file mode 100644
index 0000000000000000000000000000000000000000..36e5d1767618d404ae9ef7a17bb7ab62bce1e0ac
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_2.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_3.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_3.pt
new file mode 100644
index 0000000000000000000000000000000000000000..e8e74bd7a72db76984c7c1d48e12ef2c44d43042
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_3.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_4.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_4.pt
new file mode 100644
index 0000000000000000000000000000000000000000..f02841fbe96b46e5590e8a124c6636fcc2848b80
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_4.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_5.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_5.pt
new file mode 100644
index 0000000000000000000000000000000000000000..09e2a3f4856e2e4e56c7538dfc8eb632e89f0987
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_5.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_6.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_6.pt
new file mode 100644
index 0000000000000000000000000000000000000000..63eb7ddfc6d3921af129980eaa14baed168b2dc4
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_6.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_7.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_7.pt
new file mode 100644
index 0000000000000000000000000000000000000000..7f00686a15d8d21702a4c9063f36fcc958eb18ba
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_7.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/chat_template.jinja b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/chat_template.jinja
new file mode 100644
index 0000000000000000000000000000000000000000..a585dec894e63da457d9440ec6aa7caa16d20860
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/chat_template.jinja
@@ -0,0 +1,154 @@
+{%- set image_count = namespace(value=0) %}
+{%- set video_count = namespace(value=0) %}
+{%- macro render_content(content, do_vision_count, is_system_content=false) %}
+ {%- if content is string %}
+ {{- content }}
+ {%- elif content is iterable and content is not mapping %}
+ {%- for item in content %}
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
+ {%- if is_system_content %}
+ {{- raise_exception('System message cannot contain images.') }}
+ {%- endif %}
+ {%- if do_vision_count %}
+ {%- set image_count.value = image_count.value + 1 %}
+ {%- endif %}
+ {%- if add_vision_id %}
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
+ {%- endif %}
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
+ {%- elif 'video' in item or item.type == 'video' %}
+ {%- if is_system_content %}
+ {{- raise_exception('System message cannot contain videos.') }}
+ {%- endif %}
+ {%- if do_vision_count %}
+ {%- set video_count.value = video_count.value + 1 %}
+ {%- endif %}
+ {%- if add_vision_id %}
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
+ {%- endif %}
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
+ {%- elif 'text' in item %}
+ {{- item.text }}
+ {%- else %}
+ {{- raise_exception('Unexpected item type in content.') }}
+ {%- endif %}
+ {%- endfor %}
+ {%- elif content is none or content is undefined %}
+ {{- '' }}
+ {%- else %}
+ {{- raise_exception('Unexpected content type.') }}
+ {%- endif %}
+{%- endmacro %}
+{%- if not messages %}
+ {{- raise_exception('No messages provided.') }}
+{%- endif %}
+{%- if tools and tools is iterable and tools is not mapping %}
+ {{- '<|im_start|>system\n' }}
+ {{- "# Tools\n\nYou have access to the following functions:\n\n" }}
+ {%- for tool in tools %}
+ {{- "\n" }}
+ {{- tool | tojson }}
+ {%- endfor %}
+ {{- "\n " }}
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n\n\n\nvalue_1\n \n\nThis is the value for the second parameter\nthat can span\nmultiple lines\n \n \n \n\n\nReminder:\n- Function calls MUST follow the specified format: an inner block must be nested within XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n ' }}
+ {%- if messages[0].role == 'system' %}
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
+ {%- if content %}
+ {{- '\n\n' + content }}
+ {%- endif %}
+ {%- endif %}
+ {{- '<|im_end|>\n' }}
+{%- else %}
+ {%- if messages[0].role == 'system' %}
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
+ {%- endif %}
+{%- endif %}
+{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
+{%- for message in messages[::-1] %}
+ {%- set index = (messages|length - 1) - loop.index0 %}
+ {%- if ns.multi_step_tool and message.role == "user" %}
+ {%- set content = render_content(message.content, false)|trim %}
+ {%- if not(content.startswith('') and content.endswith(' ')) %}
+ {%- set ns.multi_step_tool = false %}
+ {%- set ns.last_query_index = index %}
+ {%- endif %}
+ {%- endif %}
+{%- endfor %}
+{%- if ns.multi_step_tool %}
+ {{- raise_exception('No user query found in messages.') }}
+{%- endif %}
+{%- for message in messages %}
+ {%- set content = render_content(message.content, true)|trim %}
+ {%- if message.role == "system" %}
+ {%- if not loop.first %}
+ {{- raise_exception('System message must be at the beginning.') }}
+ {%- endif %}
+ {%- elif message.role == "user" %}
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
+ {%- elif message.role == "assistant" %}
+ {%- set reasoning_content = '' %}
+ {%- if message.reasoning_content is string %}
+ {%- set reasoning_content = message.reasoning_content %}
+ {%- else %}
+ {%- if '' in content %}
+ {%- set reasoning_content = content.split('')[0].rstrip('\n').split('')[-1].lstrip('\n') %}
+ {%- set content = content.split(' ')[-1].lstrip('\n') %}
+ {%- endif %}
+ {%- endif %}
+ {%- set reasoning_content = reasoning_content|trim %}
+ {%- if loop.index0 > ns.last_query_index %}
+ {{- '<|im_start|>' + message.role + '\n\n' + reasoning_content + '\n \n\n' + content }}
+ {%- else %}
+ {{- '<|im_start|>' + message.role + '\n' + content }}
+ {%- endif %}
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
+ {%- for tool_call in message.tool_calls %}
+ {%- if tool_call.function is defined %}
+ {%- set tool_call = tool_call.function %}
+ {%- endif %}
+ {%- if loop.first %}
+ {%- if content|trim %}
+ {{- '\n\n\n\n' }}
+ {%- else %}
+ {{- '\n\n' }}
+ {%- endif %}
+ {%- else %}
+ {{- '\n\n\n' }}
+ {%- endif %}
+ {%- if tool_call.arguments is defined %}
+ {%- for args_name, args_value in tool_call.arguments|items %}
+ {{- '\n' }}
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
+ {{- args_value }}
+ {{- '\n \n' }}
+ {%- endfor %}
+ {%- endif %}
+ {{- ' \n ' }}
+ {%- endfor %}
+ {%- endif %}
+ {{- '<|im_end|>\n' }}
+ {%- elif message.role == "tool" %}
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
+ {{- '<|im_start|>user' }}
+ {%- endif %}
+ {{- '\n\n' }}
+ {{- content }}
+ {{- '\n ' }}
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
+ {{- '<|im_end|>\n' }}
+ {%- elif loop.last %}
+ {{- '<|im_end|>\n' }}
+ {%- endif %}
+ {%- else %}
+ {{- raise_exception('Unexpected message role.') }}
+ {%- endif %}
+{%- endfor %}
+{%- if add_generation_prompt %}
+ {{- '<|im_start|>assistant\n' }}
+ {%- if enable_thinking is defined and enable_thinking is false %}
+ {{- '\n\n \n\n' }}
+ {%- else %}
+ {{- '\n' }}
+ {%- endif %}
+{%- endif %}
\ No newline at end of file
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/config.json b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/config.json
new file mode 100644
index 0000000000000000000000000000000000000000..d13e73613c775be9cd78fc28ff9c7aa65090debc
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/config.json
@@ -0,0 +1,109 @@
+{
+ "architectures": [
+ "Qwen3_5ForConditionalGeneration"
+ ],
+ "dtype": "bfloat16",
+ "image_token_id": 248056,
+ "model_type": "qwen3_5",
+ "text_config": {
+ "attention_bias": false,
+ "attention_dropout": 0.0,
+ "attn_output_gate": true,
+ "bos_token_id": null,
+ "dtype": "bfloat16",
+ "eos_token_id": 248044,
+ "full_attention_interval": 4,
+ "head_dim": 256,
+ "hidden_act": "silu",
+ "hidden_size": 4096,
+ "initializer_range": 0.02,
+ "intermediate_size": 12288,
+ "layer_types": [
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention"
+ ],
+ "linear_conv_kernel_dim": 4,
+ "linear_key_head_dim": 128,
+ "linear_num_key_heads": 16,
+ "linear_num_value_heads": 32,
+ "linear_value_head_dim": 128,
+ "mamba_ssm_dtype": "float32",
+ "max_position_embeddings": 262144,
+ "mlp_only_layers": [],
+ "model_type": "qwen3_5_text",
+ "mtp_num_hidden_layers": 0,
+ "mtp_use_dedicated_embeddings": false,
+ "num_attention_heads": 16,
+ "num_hidden_layers": 32,
+ "num_key_value_heads": 4,
+ "pad_token_id": null,
+ "partial_rotary_factor": 0.25,
+ "rms_norm_eps": 1e-06,
+ "rope_parameters": {
+ "mrope_interleaved": true,
+ "mrope_section": [
+ 11,
+ 11,
+ 10
+ ],
+ "partial_rotary_factor": 0.25,
+ "rope_theta": 10000000,
+ "rope_type": "default"
+ },
+ "tie_word_embeddings": false,
+ "use_cache": true,
+ "vocab_size": 248320
+ },
+ "tie_word_embeddings": false,
+ "transformers_version": "5.5.4",
+ "video_token_id": 248057,
+ "vision_config": {
+ "deepstack_visual_indexes": [],
+ "depth": 27,
+ "dtype": "bfloat16",
+ "hidden_act": "gelu_pytorch_tanh",
+ "hidden_size": 1152,
+ "in_channels": 3,
+ "initializer_range": 0.02,
+ "intermediate_size": 4304,
+ "model_type": "qwen3_5",
+ "num_heads": 16,
+ "num_position_embeddings": 2304,
+ "out_hidden_size": 4096,
+ "patch_size": 16,
+ "spatial_merge_size": 2,
+ "temporal_patch_size": 2
+ },
+ "vision_end_token_id": 248054,
+ "vision_start_token_id": 248053
+}
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/generation_config.json b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/generation_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..23a0a961f467960631ac5faedc9547a7523a99c3
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/generation_config.json
@@ -0,0 +1,13 @@
+{
+ "bos_token_id": 248044,
+ "do_sample": true,
+ "eos_token_id": [
+ 248046,
+ 248044
+ ],
+ "pad_token_id": 248044,
+ "temperature": 1.0,
+ "top_k": 20,
+ "top_p": 0.95,
+ "transformers_version": "5.5.4"
+}
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/processor_config.json b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/processor_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..33818c7f9e991ad735fd240209f4fa73e6c28c50
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/processor_config.json
@@ -0,0 +1,60 @@
+{
+ "image_processor": {
+ "do_convert_rgb": true,
+ "do_normalize": true,
+ "do_rescale": true,
+ "do_resize": true,
+ "image_mean": [
+ 0.5,
+ 0.5,
+ 0.5
+ ],
+ "image_processor_type": "Qwen2VLImageProcessor",
+ "image_std": [
+ 0.5,
+ 0.5,
+ 0.5
+ ],
+ "merge_size": 2,
+ "patch_size": 16,
+ "resample": 3,
+ "rescale_factor": 0.00392156862745098,
+ "size": {
+ "longest_edge": 16777216,
+ "shortest_edge": 65536
+ },
+ "temporal_patch_size": 2
+ },
+ "processor_class": "Qwen3VLProcessor",
+ "video_processor": {
+ "do_convert_rgb": true,
+ "do_normalize": true,
+ "do_rescale": true,
+ "do_resize": true,
+ "do_sample_frames": true,
+ "fps": 2,
+ "image_mean": [
+ 0.5,
+ 0.5,
+ 0.5
+ ],
+ "image_std": [
+ 0.5,
+ 0.5,
+ 0.5
+ ],
+ "max_frames": 768,
+ "merge_size": 2,
+ "min_frames": 4,
+ "patch_size": 16,
+ "resample": 3,
+ "rescale_factor": 0.00392156862745098,
+ "return_metadata": false,
+ "size": {
+ "longest_edge": 25165824,
+ "shortest_edge": 4096
+ },
+ "temporal_patch_size": 2,
+ "video_processor_type": "Qwen3VLVideoProcessor"
+ }
+}
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/tokenizer.json b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/tokenizer.json
new file mode 100644
index 0000000000000000000000000000000000000000..67741b04f23bfdb46501f748ce27865ec82eccfb
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/tokenizer.json
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:87a7830d63fcf43bf241c3c5242e96e62dd3fdc29224ca26fed8ea333db72de4
+size 19989343
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/tokenizer_config.json b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/tokenizer_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..a068e2468cff426a9b105006e74e044030a6faf4
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/huggingface/tokenizer_config.json
@@ -0,0 +1,32 @@
+{
+ "add_prefix_space": false,
+ "audio_bos_token": "<|audio_start|>",
+ "audio_eos_token": "<|audio_end|>",
+ "audio_token": "<|audio_pad|>",
+ "backend": "tokenizers",
+ "bos_token": null,
+ "clean_up_tokenization_spaces": false,
+ "eos_token": "<|im_end|>",
+ "errors": "replace",
+ "image_token": "<|image_pad|>",
+ "is_local": true,
+ "model_max_length": 262144,
+ "model_specific_special_tokens": {
+ "audio_bos_token": "<|audio_start|>",
+ "audio_eos_token": "<|audio_end|>",
+ "audio_token": "<|audio_pad|>",
+ "image_token": "<|image_pad|>",
+ "video_token": "<|video_pad|>",
+ "vision_bos_token": "<|vision_start|>",
+ "vision_eos_token": "<|vision_end|>"
+ },
+ "pad_token": "<|endoftext|>",
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
+ "processor_class": "Qwen3VLProcessor",
+ "split_special_tokens": false,
+ "tokenizer_class": "TokenizersBackend",
+ "unk_token": null,
+ "video_token": "<|video_pad|>",
+ "vision_bos_token": "<|vision_start|>",
+ "vision_eos_token": "<|vision_end|>"
+}
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_0.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_0.pt
new file mode 100644
index 0000000000000000000000000000000000000000..880f25f27588d18338bad74731696b06ad1d5069
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_0.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e7a112d3ef8e9efd0c7404343835e8d1cacb68f1bf47ec9e70a6035d628bfeae
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_1.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_1.pt
new file mode 100644
index 0000000000000000000000000000000000000000..8ef782c0875bd58d1b344b7b9210181f0a73c911
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_1.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:929bde074b4648a5e40ea9d59f1b37f587f605044b3013c2d9663496ff270997
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_2.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_2.pt
new file mode 100644
index 0000000000000000000000000000000000000000..f058aa02326eebbfdd6ada84a9fca495c4e8d8c2
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_2.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5f191a9b777c259be21b12b8ed02585b790915f74c547f0ac2600a45ce3a598a
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_3.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_3.pt
new file mode 100644
index 0000000000000000000000000000000000000000..4eaa7a0a3388cef91ac20367d03a2844da7264ef
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_3.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ff8e73e975bbe1ea882acbebe7533b050a9c127beb1c39b8a5d3cfd72ffbc17c
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_4.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_4.pt
new file mode 100644
index 0000000000000000000000000000000000000000..43b28746068bd1bc4f689d867d183ee0a6b2d845
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_4.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:49a0e15c74448d5debb04be1f773463910f08df82e32e33fc164e1e674a1d3e2
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_5.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_5.pt
new file mode 100644
index 0000000000000000000000000000000000000000..99a1dcf49b6a2a434da998b7c75ed95ca8cf2f84
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_5.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:84a14b9ff89d4c3769d06de5e98af7f71b0b2c7d82226c3e9761d37af480a7db
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_6.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_6.pt
new file mode 100644
index 0000000000000000000000000000000000000000..93876eceb78280fcfdeebc8a43cb000e143864b7
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_6.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:53206b46e825c183bee092c93d115c7863e368745f0b4a9340f477845fff6521
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_7.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_7.pt
new file mode 100644
index 0000000000000000000000000000000000000000..6dad326e8199db4e1d35257f90ea26820f3d8378
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_7.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5f69907e184a8e128ebd0087e9e47ebd62f2cfa47dd93b356b2b03df20317cda
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_0.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_0.pt
new file mode 100644
index 0000000000000000000000000000000000000000..96582c2dad336962a44f62e4cac0203859483a87
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_0.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dc1018349737fd19af9fe2ae532ee8aed53a1cd2a6289bd51efc0e893092b5e7
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_1.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_1.pt
new file mode 100644
index 0000000000000000000000000000000000000000..f62cef8911fdf35efc7061d2221bc4606731850c
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_1.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:72b7df9ff763ae286ab239e33b2d6711bff9f19a16f9043418b8c68accc08cb2
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_2.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_2.pt
new file mode 100644
index 0000000000000000000000000000000000000000..09b9bd2a1e73a35547da89bba6ad3648bb0920ad
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_2.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fad4ce51bcfbc75a0bd746f41af31c8c44cd57974acc1a36adcc4153bf303a6b
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_3.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_3.pt
new file mode 100644
index 0000000000000000000000000000000000000000..aa6b5487fb06482430ec76e98a0654e0605b4cab
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_3.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:11ed1bcf8aa7199792a3f094d8844b6840cdd53efb3fcdfd7f1ea6931b07ea75
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_4.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_4.pt
new file mode 100644
index 0000000000000000000000000000000000000000..2b5480769f41a56f2df48815aec56d3e40ce5384
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_4.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ccb3231b0406316170091c991dd49652d43e89bcc3f8544cbe436742baf2e960
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_5.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_5.pt
new file mode 100644
index 0000000000000000000000000000000000000000..2b57e8782e887d1442ec5b5c931eb9ad2249a8e6
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_5.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8b1e07b9349724c6f93fd95d24146c217483921a201c48db3cd1742e22527b70
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_6.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_6.pt
new file mode 100644
index 0000000000000000000000000000000000000000..5e407504f5f07f27deaa2e30082b11fd8111c5f1
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_6.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:92c3605f87040e3ee654db866052d94620101854bc2ee697c10a0536bc5b3dbb
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_7.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_7.pt
new file mode 100644
index 0000000000000000000000000000000000000000..4829ff68eb7dff55f11034b8df1e65bbfc6769c0
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_7.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c2f8b77f8faef58fce850c407f800d2cd0e7c9e4388e7c7a08f6465357fb9891
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/dataloader.pt b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/dataloader.pt
new file mode 100644
index 0000000000000000000000000000000000000000..237b2a50543b7937e88f70166e880f4e7a02342f
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/dataloader.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/checkpoint_tracker.json b/code/runs/smoke-training-9b-20260825_175949/orarl/checkpoint_tracker.json
new file mode 100644
index 0000000000000000000000000000000000000000..89e57145ec755ed0b11d88f01c9cda722eb0237d
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/checkpoint_tracker.json
@@ -0,0 +1,6 @@
+{
+ "best_global_step": null,
+ "best_val_reward_score": -1.0,
+ "last_global_step": 1,
+ "last_actor_path": "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor"
+}
\ No newline at end of file
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_0.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_0.pt
new file mode 100644
index 0000000000000000000000000000000000000000..9ddf9fa959f9f7e04210a74d1c92d33701d6b33e
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_0.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_1.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_1.pt
new file mode 100644
index 0000000000000000000000000000000000000000..ff1c6d6f862719840b74d28237a295340dabc090
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_1.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_2.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_2.pt
new file mode 100644
index 0000000000000000000000000000000000000000..36e5d1767618d404ae9ef7a17bb7ab62bce1e0ac
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_2.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_3.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_3.pt
new file mode 100644
index 0000000000000000000000000000000000000000..e8e74bd7a72db76984c7c1d48e12ef2c44d43042
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_3.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_4.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_4.pt
new file mode 100644
index 0000000000000000000000000000000000000000..f02841fbe96b46e5590e8a124c6636fcc2848b80
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_4.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_5.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_5.pt
new file mode 100644
index 0000000000000000000000000000000000000000..09e2a3f4856e2e4e56c7538dfc8eb632e89f0987
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_5.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_6.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_6.pt
new file mode 100644
index 0000000000000000000000000000000000000000..63eb7ddfc6d3921af129980eaa14baed168b2dc4
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_6.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_7.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_7.pt
new file mode 100644
index 0000000000000000000000000000000000000000..7f00686a15d8d21702a4c9063f36fcc958eb18ba
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_7.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/chat_template.jinja b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/chat_template.jinja
new file mode 100644
index 0000000000000000000000000000000000000000..a585dec894e63da457d9440ec6aa7caa16d20860
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/chat_template.jinja
@@ -0,0 +1,154 @@
+{%- set image_count = namespace(value=0) %}
+{%- set video_count = namespace(value=0) %}
+{%- macro render_content(content, do_vision_count, is_system_content=false) %}
+ {%- if content is string %}
+ {{- content }}
+ {%- elif content is iterable and content is not mapping %}
+ {%- for item in content %}
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
+ {%- if is_system_content %}
+ {{- raise_exception('System message cannot contain images.') }}
+ {%- endif %}
+ {%- if do_vision_count %}
+ {%- set image_count.value = image_count.value + 1 %}
+ {%- endif %}
+ {%- if add_vision_id %}
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
+ {%- endif %}
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
+ {%- elif 'video' in item or item.type == 'video' %}
+ {%- if is_system_content %}
+ {{- raise_exception('System message cannot contain videos.') }}
+ {%- endif %}
+ {%- if do_vision_count %}
+ {%- set video_count.value = video_count.value + 1 %}
+ {%- endif %}
+ {%- if add_vision_id %}
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
+ {%- endif %}
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
+ {%- elif 'text' in item %}
+ {{- item.text }}
+ {%- else %}
+ {{- raise_exception('Unexpected item type in content.') }}
+ {%- endif %}
+ {%- endfor %}
+ {%- elif content is none or content is undefined %}
+ {{- '' }}
+ {%- else %}
+ {{- raise_exception('Unexpected content type.') }}
+ {%- endif %}
+{%- endmacro %}
+{%- if not messages %}
+ {{- raise_exception('No messages provided.') }}
+{%- endif %}
+{%- if tools and tools is iterable and tools is not mapping %}
+ {{- '<|im_start|>system\n' }}
+ {{- "# Tools\n\nYou have access to the following functions:\n\n" }}
+ {%- for tool in tools %}
+ {{- "\n" }}
+ {{- tool | tojson }}
+ {%- endfor %}
+ {{- "\n " }}
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n\n\n\nvalue_1\n \n\nThis is the value for the second parameter\nthat can span\nmultiple lines\n \n \n \n\n\nReminder:\n- Function calls MUST follow the specified format: an inner block must be nested within XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n ' }}
+ {%- if messages[0].role == 'system' %}
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
+ {%- if content %}
+ {{- '\n\n' + content }}
+ {%- endif %}
+ {%- endif %}
+ {{- '<|im_end|>\n' }}
+{%- else %}
+ {%- if messages[0].role == 'system' %}
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
+ {%- endif %}
+{%- endif %}
+{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
+{%- for message in messages[::-1] %}
+ {%- set index = (messages|length - 1) - loop.index0 %}
+ {%- if ns.multi_step_tool and message.role == "user" %}
+ {%- set content = render_content(message.content, false)|trim %}
+ {%- if not(content.startswith('') and content.endswith(' ')) %}
+ {%- set ns.multi_step_tool = false %}
+ {%- set ns.last_query_index = index %}
+ {%- endif %}
+ {%- endif %}
+{%- endfor %}
+{%- if ns.multi_step_tool %}
+ {{- raise_exception('No user query found in messages.') }}
+{%- endif %}
+{%- for message in messages %}
+ {%- set content = render_content(message.content, true)|trim %}
+ {%- if message.role == "system" %}
+ {%- if not loop.first %}
+ {{- raise_exception('System message must be at the beginning.') }}
+ {%- endif %}
+ {%- elif message.role == "user" %}
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
+ {%- elif message.role == "assistant" %}
+ {%- set reasoning_content = '' %}
+ {%- if message.reasoning_content is string %}
+ {%- set reasoning_content = message.reasoning_content %}
+ {%- else %}
+ {%- if ' ' in content %}
+ {%- set reasoning_content = content.split('')[0].rstrip('\n').split('')[-1].lstrip('\n') %}
+ {%- set content = content.split(' ')[-1].lstrip('\n') %}
+ {%- endif %}
+ {%- endif %}
+ {%- set reasoning_content = reasoning_content|trim %}
+ {%- if loop.index0 > ns.last_query_index %}
+ {{- '<|im_start|>' + message.role + '\n\n' + reasoning_content + '\n \n\n' + content }}
+ {%- else %}
+ {{- '<|im_start|>' + message.role + '\n' + content }}
+ {%- endif %}
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
+ {%- for tool_call in message.tool_calls %}
+ {%- if tool_call.function is defined %}
+ {%- set tool_call = tool_call.function %}
+ {%- endif %}
+ {%- if loop.first %}
+ {%- if content|trim %}
+ {{- '\n\n\n\n' }}
+ {%- else %}
+ {{- '\n\n' }}
+ {%- endif %}
+ {%- else %}
+ {{- '\n\n\n' }}
+ {%- endif %}
+ {%- if tool_call.arguments is defined %}
+ {%- for args_name, args_value in tool_call.arguments|items %}
+ {{- '\n' }}
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
+ {{- args_value }}
+ {{- '\n \n' }}
+ {%- endfor %}
+ {%- endif %}
+ {{- ' \n ' }}
+ {%- endfor %}
+ {%- endif %}
+ {{- '<|im_end|>\n' }}
+ {%- elif message.role == "tool" %}
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
+ {{- '<|im_start|>user' }}
+ {%- endif %}
+ {{- '\n\n' }}
+ {{- content }}
+ {{- '\n ' }}
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
+ {{- '<|im_end|>\n' }}
+ {%- elif loop.last %}
+ {{- '<|im_end|>\n' }}
+ {%- endif %}
+ {%- else %}
+ {{- raise_exception('Unexpected message role.') }}
+ {%- endif %}
+{%- endfor %}
+{%- if add_generation_prompt %}
+ {{- '<|im_start|>assistant\n' }}
+ {%- if enable_thinking is defined and enable_thinking is false %}
+ {{- '\n\n \n\n' }}
+ {%- else %}
+ {{- '\n' }}
+ {%- endif %}
+{%- endif %}
\ No newline at end of file
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/config.json b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/config.json
new file mode 100644
index 0000000000000000000000000000000000000000..d13e73613c775be9cd78fc28ff9c7aa65090debc
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/config.json
@@ -0,0 +1,109 @@
+{
+ "architectures": [
+ "Qwen3_5ForConditionalGeneration"
+ ],
+ "dtype": "bfloat16",
+ "image_token_id": 248056,
+ "model_type": "qwen3_5",
+ "text_config": {
+ "attention_bias": false,
+ "attention_dropout": 0.0,
+ "attn_output_gate": true,
+ "bos_token_id": null,
+ "dtype": "bfloat16",
+ "eos_token_id": 248044,
+ "full_attention_interval": 4,
+ "head_dim": 256,
+ "hidden_act": "silu",
+ "hidden_size": 4096,
+ "initializer_range": 0.02,
+ "intermediate_size": 12288,
+ "layer_types": [
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention",
+ "linear_attention",
+ "linear_attention",
+ "linear_attention",
+ "full_attention"
+ ],
+ "linear_conv_kernel_dim": 4,
+ "linear_key_head_dim": 128,
+ "linear_num_key_heads": 16,
+ "linear_num_value_heads": 32,
+ "linear_value_head_dim": 128,
+ "mamba_ssm_dtype": "float32",
+ "max_position_embeddings": 262144,
+ "mlp_only_layers": [],
+ "model_type": "qwen3_5_text",
+ "mtp_num_hidden_layers": 0,
+ "mtp_use_dedicated_embeddings": false,
+ "num_attention_heads": 16,
+ "num_hidden_layers": 32,
+ "num_key_value_heads": 4,
+ "pad_token_id": null,
+ "partial_rotary_factor": 0.25,
+ "rms_norm_eps": 1e-06,
+ "rope_parameters": {
+ "mrope_interleaved": true,
+ "mrope_section": [
+ 11,
+ 11,
+ 10
+ ],
+ "partial_rotary_factor": 0.25,
+ "rope_theta": 10000000,
+ "rope_type": "default"
+ },
+ "tie_word_embeddings": false,
+ "use_cache": true,
+ "vocab_size": 248320
+ },
+ "tie_word_embeddings": false,
+ "transformers_version": "5.5.4",
+ "video_token_id": 248057,
+ "vision_config": {
+ "deepstack_visual_indexes": [],
+ "depth": 27,
+ "dtype": "bfloat16",
+ "hidden_act": "gelu_pytorch_tanh",
+ "hidden_size": 1152,
+ "in_channels": 3,
+ "initializer_range": 0.02,
+ "intermediate_size": 4304,
+ "model_type": "qwen3_5",
+ "num_heads": 16,
+ "num_position_embeddings": 2304,
+ "out_hidden_size": 4096,
+ "patch_size": 16,
+ "spatial_merge_size": 2,
+ "temporal_patch_size": 2
+ },
+ "vision_end_token_id": 248054,
+ "vision_start_token_id": 248053
+}
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/generation_config.json b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/generation_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..23a0a961f467960631ac5faedc9547a7523a99c3
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/generation_config.json
@@ -0,0 +1,13 @@
+{
+ "bos_token_id": 248044,
+ "do_sample": true,
+ "eos_token_id": [
+ 248046,
+ 248044
+ ],
+ "pad_token_id": 248044,
+ "temperature": 1.0,
+ "top_k": 20,
+ "top_p": 0.95,
+ "transformers_version": "5.5.4"
+}
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/processor_config.json b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/processor_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..33818c7f9e991ad735fd240209f4fa73e6c28c50
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/processor_config.json
@@ -0,0 +1,60 @@
+{
+ "image_processor": {
+ "do_convert_rgb": true,
+ "do_normalize": true,
+ "do_rescale": true,
+ "do_resize": true,
+ "image_mean": [
+ 0.5,
+ 0.5,
+ 0.5
+ ],
+ "image_processor_type": "Qwen2VLImageProcessor",
+ "image_std": [
+ 0.5,
+ 0.5,
+ 0.5
+ ],
+ "merge_size": 2,
+ "patch_size": 16,
+ "resample": 3,
+ "rescale_factor": 0.00392156862745098,
+ "size": {
+ "longest_edge": 16777216,
+ "shortest_edge": 65536
+ },
+ "temporal_patch_size": 2
+ },
+ "processor_class": "Qwen3VLProcessor",
+ "video_processor": {
+ "do_convert_rgb": true,
+ "do_normalize": true,
+ "do_rescale": true,
+ "do_resize": true,
+ "do_sample_frames": true,
+ "fps": 2,
+ "image_mean": [
+ 0.5,
+ 0.5,
+ 0.5
+ ],
+ "image_std": [
+ 0.5,
+ 0.5,
+ 0.5
+ ],
+ "max_frames": 768,
+ "merge_size": 2,
+ "min_frames": 4,
+ "patch_size": 16,
+ "resample": 3,
+ "rescale_factor": 0.00392156862745098,
+ "return_metadata": false,
+ "size": {
+ "longest_edge": 25165824,
+ "shortest_edge": 4096
+ },
+ "temporal_patch_size": 2,
+ "video_processor_type": "Qwen3VLVideoProcessor"
+ }
+}
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/tokenizer.json b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/tokenizer.json
new file mode 100644
index 0000000000000000000000000000000000000000..67741b04f23bfdb46501f748ce27865ec82eccfb
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/tokenizer.json
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:87a7830d63fcf43bf241c3c5242e96e62dd3fdc29224ca26fed8ea333db72de4
+size 19989343
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/tokenizer_config.json b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/tokenizer_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..a068e2468cff426a9b105006e74e044030a6faf4
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/huggingface/tokenizer_config.json
@@ -0,0 +1,32 @@
+{
+ "add_prefix_space": false,
+ "audio_bos_token": "<|audio_start|>",
+ "audio_eos_token": "<|audio_end|>",
+ "audio_token": "<|audio_pad|>",
+ "backend": "tokenizers",
+ "bos_token": null,
+ "clean_up_tokenization_spaces": false,
+ "eos_token": "<|im_end|>",
+ "errors": "replace",
+ "image_token": "<|image_pad|>",
+ "is_local": true,
+ "model_max_length": 262144,
+ "model_specific_special_tokens": {
+ "audio_bos_token": "<|audio_start|>",
+ "audio_eos_token": "<|audio_end|>",
+ "audio_token": "<|audio_pad|>",
+ "image_token": "<|image_pad|>",
+ "video_token": "<|video_pad|>",
+ "vision_bos_token": "<|vision_start|>",
+ "vision_eos_token": "<|vision_end|>"
+ },
+ "pad_token": "<|endoftext|>",
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
+ "processor_class": "Qwen3VLProcessor",
+ "split_special_tokens": false,
+ "tokenizer_class": "TokenizersBackend",
+ "unk_token": null,
+ "video_token": "<|video_pad|>",
+ "vision_bos_token": "<|vision_start|>",
+ "vision_eos_token": "<|vision_end|>"
+}
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_0.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_0.pt
new file mode 100644
index 0000000000000000000000000000000000000000..965bcc65404bdb192443f340dcb3d2ae70da0d82
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_0.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:68d916aca907a48f093fcb05501c28b5e0a46a127dae4e7077e1bf7da273a578
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_1.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_1.pt
new file mode 100644
index 0000000000000000000000000000000000000000..59aac22dc11af461393131f923317783bbf79a9a
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_1.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:59a86054eef4b1dc5ad060c150e8435918f4221890f037bef4800944e065ac9c
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_2.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_2.pt
new file mode 100644
index 0000000000000000000000000000000000000000..d61124c62cf6d85809c98cde069ab62cc93e5fbc
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_2.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dbdea55e015c41e8b330efd62a209990d6396cc7e9bbefb3db3a01615e130094
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_3.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_3.pt
new file mode 100644
index 0000000000000000000000000000000000000000..b943ad5dee63423f2944d482cafc8fb16d7b28af
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_3.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2a4b11162a92caf7e46a3df91b572fc32bd6e899d751e80b5c29e24f44bb3d3d
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_4.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_4.pt
new file mode 100644
index 0000000000000000000000000000000000000000..533a04be9c2909452ad93c4908a226ac8f2b0f8c
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_4.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b967583161f9f4c2e32ad4e4e1e6143aef915a0eb86d8cc648bbba99b1110fd4
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_5.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_5.pt
new file mode 100644
index 0000000000000000000000000000000000000000..94deff6a447bd4c4a12ef7e467bd95b190ebd5ed
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_5.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:681ba029790747845f2e435e9515175d2f62475f8c5df86d8cd73641c8805790
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_6.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_6.pt
new file mode 100644
index 0000000000000000000000000000000000000000..8896d9b1ec7c92dd781bb0f3c16a2305db3b4313
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_6.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3a8d4c40826eb2dd2f293b9d24132b7099c718f232badccc7595930d9567b8e8
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_7.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_7.pt
new file mode 100644
index 0000000000000000000000000000000000000000..00bacbdd1dbdeaae2ff6794802f06bb5d2dd5f14
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_7.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:629e7633a242b848283f4f61d2a8e63a7b3e1bc7ed1fa96ee8820c7145700c4d
+size 2352797236
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_0.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_0.pt
new file mode 100644
index 0000000000000000000000000000000000000000..f14b6d6ad7ea58e61effea441cb67d17545f3561
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_0.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9f55ce17accdc1232fa2501877d6ed35f409bd8d6dc4cc9374f193bc5623ed20
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_1.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_1.pt
new file mode 100644
index 0000000000000000000000000000000000000000..4908c9a622c8f0bbe76f4102754bd7f2e9b5c71c
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_1.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4c8607d4247f9852139f11f1ac2fea7630177c0bccdb25383a75b549ebb3fe0c
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_2.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_2.pt
new file mode 100644
index 0000000000000000000000000000000000000000..3082d4018cd73ad883996034c5bde012cb49576f
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_2.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:783a7bdb5c7289b31bb284c1b5f4e1fbcdc14b0386e61c12d33a29a624f619e5
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_3.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_3.pt
new file mode 100644
index 0000000000000000000000000000000000000000..6bf10bb33bf914ba7a4692396058035a1d445e5d
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_3.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1e47f4381f34e93df73d5005da02800a003e906bab5ec093ec433a4b9fc0234f
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_4.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_4.pt
new file mode 100644
index 0000000000000000000000000000000000000000..cbe647e71dd1f6074b22248d1a279e2c387fb130
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_4.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a275a020afff2f926632a3c18dfc66d881e44a8a39922107128102d7afdfa076
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_5.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_5.pt
new file mode 100644
index 0000000000000000000000000000000000000000..1e46e5c6f599fd7888466d152a8665715293600c
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_5.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f1fb064bf27a05dd600fcac0bf25685dae85254178e71fc05e7413e547a415cb
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_6.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_6.pt
new file mode 100644
index 0000000000000000000000000000000000000000..c8e0a2d15efe3f25b6cbc6ec894443c9489707c7
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_6.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:719541230bc7a694e4f98a0162f19b5bf70adabd627e43df1a7cb3832661f45d
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_7.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_7.pt
new file mode 100644
index 0000000000000000000000000000000000000000..16bc4663efd6064401fa86e7ae3bb19bc6488665
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_7.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:53aabad2855f21fb69be25b681885b9c45d2cbbe9ce0ab8bd077f9f81be6b6b5
+size 4477429805
diff --git a/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/dataloader.pt b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/dataloader.pt
new file mode 100644
index 0000000000000000000000000000000000000000..237b2a50543b7937e88f70166e880f4e7a02342f
Binary files /dev/null and b/code/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/dataloader.pt differ
diff --git a/code/runs/smoke-training-9b-20260825_175949/smoke.log b/code/runs/smoke-training-9b-20260825_175949/smoke.log
new file mode 100644
index 0000000000000000000000000000000000000000..6c47f32837c70e758a87251ad7564cfeba5494e0
--- /dev/null
+++ b/code/runs/smoke-training-9b-20260825_175949/smoke.log
@@ -0,0 +1,5748 @@
+OraRL root: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official
+Python: /data/miniconda3/envs/orarl/bin/python
+Model: /apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B
+Train data: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl
+Val data: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl
+Recipe scale: 9b
+World size: 1 x 8
+Steps per run: 1
+Results: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949
+Execute: 1
+0, NVIDIA H20, 0 MiB, 97871 MiB
+1, NVIDIA H20, 0 MiB, 97871 MiB
+2, NVIDIA H20, 0 MiB, 97871 MiB
+3, NVIDIA H20, 0 MiB, 97871 MiB
+4, NVIDIA H20, 0 MiB, 97871 MiB
+5, NVIDIA H20, 0 MiB, 97871 MiB
+6, NVIDIA H20, 0 MiB, 97871 MiB
+7, NVIDIA H20, 0 MiB, 97871 MiB
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+ warnings.warn(
+dependency OK: numpy 2.2.6
+dependency OK: torch 2.10.0+cu129
+dependency OK: transformers 5.5.4
+dependency OK: vllm 0.19.1
+dependency OK: ray 2.54.0
+dependency OK: tensordict 0.12.2
+dependency OK: codetiming 1.4.0
+dependency OK: omegaconf 2.3.0
+dependency OK: verl.trainer.main /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/verl/trainer/main.py
+dependency OK: orarl.rewards /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/orarl/rewards/__init__.py
+
+=== grpo smoke (1 update(s)) ===
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+ warnings.warn(
+2026-08-25 18:00:17,309 INFO worker.py:2004 -- Started a local Ray instance. View the dashboard at [1m[32mhttp://127.0.0.1:8265 [39m[22m
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/worker.py:2052: FutureWarning: Tip: In future versions of Ray, Ray will no longer override accelerator visible devices env var if num_gpus=0 or num_gpus=None (default). To enable this behavior and turn off this error message, set RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO=0
+ warnings.warn(
+[36m(pid=2031595)[0m W0825 18:00:33.227000 2031595 site-packages/torch/utils/cpp_extension.py:118] No CUDA runtime is found, using CUDA_HOME='/data/miniconda3/envs/orarl'
+[36m(Runner pid=2031595)[0m {
+[36m(Runner pid=2031595)[0m "data": {
+[36m(Runner pid=2031595)[0m "train_files": "/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl",
+[36m(Runner pid=2031595)[0m "val_files": "/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl",
+[36m(Runner pid=2031595)[0m "prompt_key": "problem",
+[36m(Runner pid=2031595)[0m "answer_key": "answer",
+[36m(Runner pid=2031595)[0m "image_key": "images",
+[36m(Runner pid=2031595)[0m "video_key": "videos",
+[36m(Runner pid=2031595)[0m "image_dir": "/",
+[36m(Runner pid=2031595)[0m "video_fps": 2.0,
+[36m(Runner pid=2031595)[0m "video_max_frames": 128,
+[36m(Runner pid=2031595)[0m "max_prompt_length": 24576,
+[36m(Runner pid=2031595)[0m "max_response_length": 4096,
+[36m(Runner pid=2031595)[0m "rollout_batch_size": 8,
+[36m(Runner pid=2031595)[0m "mini_rollout_batch_size": null,
+[36m(Runner pid=2031595)[0m "val_batch_size": 16,
+[36m(Runner pid=2031595)[0m "format_prompt": null,
+[36m(Runner pid=2031595)[0m "override_chat_template": null,
+[36m(Runner pid=2031595)[0m "enable_thinking": false,
+[36m(Runner pid=2031595)[0m "response_prefix": "",
+[36m(Runner pid=2031595)[0m "shuffle": true,
+[36m(Runner pid=2031595)[0m "seed": 42,
+[36m(Runner pid=2031595)[0m "min_pixels": 4096,
+[36m(Runner pid=2031595)[0m "max_pixels": 4194304,
+[36m(Runner pid=2031595)[0m "image_min_pixels": 4096,
+[36m(Runner pid=2031595)[0m "image_max_pixels": 1048576,
+[36m(Runner pid=2031595)[0m "video_min_pixels": 4096,
+[36m(Runner pid=2031595)[0m "video_max_pixels": 786432,
+[36m(Runner pid=2031595)[0m "video_total_pixels": 8388608,
+[36m(Runner pid=2031595)[0m "val_video_fps": 2.0,
+[36m(Runner pid=2031595)[0m "val_video_max_frames": 128,
+[36m(Runner pid=2031595)[0m "val_video_min_pixels": 4096,
+[36m(Runner pid=2031595)[0m "val_video_max_pixels": 786432,
+[36m(Runner pid=2031595)[0m "val_video_total_pixels": 8388608,
+[36m(Runner pid=2031595)[0m "filter_overlong_prompts": false,
+[36m(Runner pid=2031595)[0m "filter_overlong_prompts_workers": 16,
+[36m(Runner pid=2031595)[0m "use_preprocessed_videos": false,
+[36m(Runner pid=2031595)[0m "video_source_mode": "realtime_only",
+[36m(Runner pid=2031595)[0m "preprocessed_video_dir": null,
+[36m(Runner pid=2031595)[0m "val_preprocessed_video_dir": null,
+[36m(Runner pid=2031595)[0m "val_video_source_mode": "realtime_only",
+[36m(Runner pid=2031595)[0m "inline_video_tensors": true,
+[36m(Runner pid=2031595)[0m "group_by_task": true,
+[36m(Runner pid=2031595)[0m "group_by_task_key": "problem_type",
+[36m(Runner pid=2031595)[0m "dataloader_num_workers": 0
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "worker": {
+[36m(Runner pid=2031595)[0m "hybrid_engine": true,
+[36m(Runner pid=2031595)[0m "actor": {
+[36m(Runner pid=2031595)[0m "strategy": "fsdp",
+[36m(Runner pid=2031595)[0m "global_batch_size": 8,
+[36m(Runner pid=2031595)[0m "micro_batch_size_per_device_for_update": 1,
+[36m(Runner pid=2031595)[0m "micro_batch_size_per_device_for_experience": 1,
+[36m(Runner pid=2031595)[0m "max_grad_norm": 1.0,
+[36m(Runner pid=2031595)[0m "clip_ratio_low": 0.2,
+[36m(Runner pid=2031595)[0m "clip_ratio_high": 0.2,
+[36m(Runner pid=2031595)[0m "clip_ratio_dual": 3.0,
+[36m(Runner pid=2031595)[0m "loss_avg_mode": "token",
+[36m(Runner pid=2031595)[0m "loss_type": "default",
+[36m(Runner pid=2031595)[0m "ppo_epochs": 1,
+[36m(Runner pid=2031595)[0m "padding_free": true,
+[36m(Runner pid=2031595)[0m "dynamic_batching": true,
+[36m(Runner pid=2031595)[0m "max_token_len_per_gpu": 32768,
+[36m(Runner pid=2031595)[0m "ulysses_size": 1,
+[36m(Runner pid=2031595)[0m "use_torch_compile": false,
+[36m(Runner pid=2031595)[0m "model": {
+[36m(Runner pid=2031595)[0m "model_path": "/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B",
+[36m(Runner pid=2031595)[0m "tokenizer_path": "/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B",
+[36m(Runner pid=2031595)[0m "override_config": {},
+[36m(Runner pid=2031595)[0m "enable_gradient_checkpointing": true,
+[36m(Runner pid=2031595)[0m "trust_remote_code": false,
+[36m(Runner pid=2031595)[0m "freeze_vision_tower": true,
+[36m(Runner pid=2031595)[0m "train_vision_merger": false
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "optim": {
+[36m(Runner pid=2031595)[0m "lr": 1e-06,
+[36m(Runner pid=2031595)[0m "betas": [
+[36m(Runner pid=2031595)[0m 0.9,
+[36m(Runner pid=2031595)[0m 0.999
+[36m(Runner pid=2031595)[0m ],
+[36m(Runner pid=2031595)[0m "weight_decay": 0.0,
+[36m(Runner pid=2031595)[0m "strategy": "adamw",
+[36m(Runner pid=2031595)[0m "lr_warmup_ratio": 0.0,
+[36m(Runner pid=2031595)[0m "lr_warmup_steps": null,
+[36m(Runner pid=2031595)[0m "min_lr_ratio": null,
+[36m(Runner pid=2031595)[0m "lr_scheduler_type": "constant",
+[36m(Runner pid=2031595)[0m "training_steps": -1
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "fsdp": {
+[36m(Runner pid=2031595)[0m "enable_full_shard": true,
+[36m(Runner pid=2031595)[0m "enable_cpu_offload": false,
+[36m(Runner pid=2031595)[0m "enable_rank0_init": true,
+[36m(Runner pid=2031595)[0m "use_orig_params": false,
+[36m(Runner pid=2031595)[0m "torch_dtype": "bf16",
+[36m(Runner pid=2031595)[0m "fsdp_size": -1,
+[36m(Runner pid=2031595)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=2031595)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=2031595)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "offload": {
+[36m(Runner pid=2031595)[0m "offload_params": false,
+[36m(Runner pid=2031595)[0m "offload_optimizer": false
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "global_batch_size_per_device": -1,
+[36m(Runner pid=2031595)[0m "disable_kl": true,
+[36m(Runner pid=2031595)[0m "use_kl_loss": false,
+[36m(Runner pid=2031595)[0m "kl_penalty": "low_var_kl",
+[36m(Runner pid=2031595)[0m "kl_coef": 0.0
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "critic": {
+[36m(Runner pid=2031595)[0m "strategy": "fsdp",
+[36m(Runner pid=2031595)[0m "global_batch_size": 256,
+[36m(Runner pid=2031595)[0m "micro_batch_size_per_device_for_update": 4,
+[36m(Runner pid=2031595)[0m "micro_batch_size_per_device_for_experience": 16,
+[36m(Runner pid=2031595)[0m "max_grad_norm": 1.0,
+[36m(Runner pid=2031595)[0m "cliprange_value": 0.5,
+[36m(Runner pid=2031595)[0m "loss_avg_mode": "token",
+[36m(Runner pid=2031595)[0m "ppo_epochs": 1,
+[36m(Runner pid=2031595)[0m "padding_free": false,
+[36m(Runner pid=2031595)[0m "dynamic_batching": true,
+[36m(Runner pid=2031595)[0m "max_token_len_per_gpu": null,
+[36m(Runner pid=2031595)[0m "ulysses_size": 1,
+[36m(Runner pid=2031595)[0m "model": {
+[36m(Runner pid=2031595)[0m "model_path": null,
+[36m(Runner pid=2031595)[0m "tokenizer_path": null,
+[36m(Runner pid=2031595)[0m "override_config": {},
+[36m(Runner pid=2031595)[0m "enable_gradient_checkpointing": true,
+[36m(Runner pid=2031595)[0m "trust_remote_code": true,
+[36m(Runner pid=2031595)[0m "freeze_vision_tower": false,
+[36m(Runner pid=2031595)[0m "train_vision_merger": false
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "optim": {
+[36m(Runner pid=2031595)[0m "lr": 1e-06,
+[36m(Runner pid=2031595)[0m "betas": [
+[36m(Runner pid=2031595)[0m 0.9,[36m(Runner pid=2031595)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.
+
+[36m(Runner pid=2031595)[0m 0.999
+[36m(Runner pid=2031595)[0m ],
+[36m(Runner pid=2031595)[0m "weight_decay": 0.01,
+[36m(Runner pid=2031595)[0m "strategy": "adamw",
+[36m(Runner pid=2031595)[0m "lr_warmup_ratio": 0.0,
+[36m(Runner pid=2031595)[0m "lr_warmup_steps": null,
+[36m(Runner pid=2031595)[0m "min_lr_ratio": null,
+[36m(Runner pid=2031595)[0m "lr_scheduler_type": "constant",
+[36m(Runner pid=2031595)[0m "training_steps": -1
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "fsdp": {
+[36m(Runner pid=2031595)[0m "enable_full_shard": true,
+[36m(Runner pid=2031595)[0m "enable_cpu_offload": false,
+[36m(Runner pid=2031595)[0m "enable_rank0_init": true,
+[36m(Runner pid=2031595)[0m "use_orig_params": false,
+[36m(Runner pid=2031595)[0m "torch_dtype": null,
+[36m(Runner pid=2031595)[0m "fsdp_size": -1,
+[36m(Runner pid=2031595)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=2031595)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=2031595)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "offload": {
+[36m(Runner pid=2031595)[0m "offload_params": false,
+[36m(Runner pid=2031595)[0m "offload_optimizer": false
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "global_batch_size_per_device": -1
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "ref": {
+[36m(Runner pid=2031595)[0m "strategy": "fsdp",
+[36m(Runner pid=2031595)[0m "fsdp": {
+[36m(Runner pid=2031595)[0m "enable_full_shard": true,
+[36m(Runner pid=2031595)[0m "enable_cpu_offload": false,
+[36m(Runner pid=2031595)[0m "enable_rank0_init": true,
+[36m(Runner pid=2031595)[0m "use_orig_params": false,
+[36m(Runner pid=2031595)[0m "torch_dtype": "bf16",
+[36m(Runner pid=2031595)[0m "fsdp_size": -1,
+[36m(Runner pid=2031595)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=2031595)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=2031595)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "offload": {
+[36m(Runner pid=2031595)[0m "offload_params": false,
+[36m(Runner pid=2031595)[0m "offload_optimizer": false
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "micro_batch_size_per_device_for_experience": 1,
+[36m(Runner pid=2031595)[0m "padding_free": true,
+[36m(Runner pid=2031595)[0m "dynamic_batching": true,
+[36m(Runner pid=2031595)[0m "max_token_len_per_gpu": 32768,
+[36m(Runner pid=2031595)[0m "ulysses_size": 1,
+[36m(Runner pid=2031595)[0m "use_torch_compile": false
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "reward": {
+[36m(Runner pid=2031595)[0m "reward_function": "orarl.rewards",
+[36m(Runner pid=2031595)[0m "reward_function_kwargs": {},
+[36m(Runner pid=2031595)[0m "skip_special_tokens": true,
+[36m(Runner pid=2031595)[0m "num_cpus": 1,
+[36m(Runner pid=2031595)[0m "reward_function_name": "compute_score",
+[36m(Runner pid=2031595)[0m "reward_function_is_module": true
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "rollout": {
+[36m(Runner pid=2031595)[0m "name": "vllm",
+[36m(Runner pid=2031595)[0m "n": 8,
+[36m(Runner pid=2031595)[0m "temperature": 1.0,
+[36m(Runner pid=2031595)[0m "top_p": 0.85,
+[36m(Runner pid=2031595)[0m "top_k": -1,
+[36m(Runner pid=2031595)[0m "seed": 42,
+[36m(Runner pid=2031595)[0m "limit_images": 10,
+[36m(Runner pid=2031595)[0m "dtype": "bf16",
+[36m(Runner pid=2031595)[0m "gpu_memory_utilization": 0.45,
+[36m(Runner pid=2031595)[0m "ignore_eos": false,
+[36m(Runner pid=2031595)[0m "enforce_eager": false,
+[36m(Runner pid=2031595)[0m "enable_chunked_prefill": true,
+[36m(Runner pid=2031595)[0m "tensor_parallel_size": 1,
+[36m(Runner pid=2031595)[0m "max_model_len": null,
+[36m(Runner pid=2031595)[0m "max_num_batched_tokens": 32768,
+[36m(Runner pid=2031595)[0m "disable_log_stats": true,
+[36m(Runner pid=2031595)[0m "disable_tqdm": true,
+[36m(Runner pid=2031595)[0m "val_override_config": {
+[36m(Runner pid=2031595)[0m "temperature": 0.7,
+[36m(Runner pid=2031595)[0m "top_p": 0.95,
+[36m(Runner pid=2031595)[0m "n": 1
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "kv_cache_dtype": "auto",
+[36m(Runner pid=2031595)[0m "calculate_log_probs": false,
+[36m(Runner pid=2031595)[0m "collect_seq_logprob_for_filter": false,
+[36m(Runner pid=2031595)[0m "prompt_length": 24576,
+[36m(Runner pid=2031595)[0m "response_length": 4096,
+[36m(Runner pid=2031595)[0m "trust_remote_code": false
+[36m(Runner pid=2031595)[0m }
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "algorithm": {
+[36m(Runner pid=2031595)[0m "name": "grpo",
+[36m(Runner pid=2031595)[0m "gamma": 1.0,
+[36m(Runner pid=2031595)[0m "lam": 1.0,
+[36m(Runner pid=2031595)[0m "adv_estimator": "grpo",
+[36m(Runner pid=2031595)[0m "scale_rewards": true,
+[36m(Runner pid=2031595)[0m "disable_kl": true,
+[36m(Runner pid=2031595)[0m "use_kl_loss": false,
+[36m(Runner pid=2031595)[0m "kl_penalty": "low_var_kl",
+[36m(Runner pid=2031595)[0m "kl_coef": 0.0,
+[36m(Runner pid=2031595)[0m "kl_type": "fixed",
+[36m(Runner pid=2031595)[0m "kl_horizon": 10000.0,
+[36m(Runner pid=2031595)[0m "kl_target": 0.1
+[36m(Runner pid=2031595)[0m },
+[36m(Runner pid=2031595)[0m "trainer": {
+[36m(Runner pid=2031595)[0m "total_epochs": 1,
+[36m(Runner pid=2031595)[0m "max_steps": 1,
+[36m(Runner pid=2031595)[0m "project_name": "OraRL",
+[36m(Runner pid=2031595)[0m "experiment_name": "smoke-grpo-9b",
+[36m(Runner pid=2031595)[0m "logger": [
+[36m(Runner pid=2031595)[0m "console"
+[36m(Runner pid=2031595)[0m ],
+[36m(Runner pid=2031595)[0m "nnodes": 1,
+[36m(Runner pid=2031595)[0m "n_gpus_per_node": 8,
+[36m(Runner pid=2031595)[0m "critic_warmup": 0,
+[36m(Runner pid=2031595)[0m "val_freq": -1,
+[36m(Runner pid=2031595)[0m "val_before_train": false,
+[36m(Runner pid=2031595)[0m "val_only": false,
+[36m(Runner pid=2031595)[0m "val_generations_to_log": 0,
+[36m(Runner pid=2031595)[0m "save_freq": 1,
+[36m(Runner pid=2031595)[0m "save_limit": 3,
+[36m(Runner pid=2031595)[0m "save_model_only": false,
+[36m(Runner pid=2031595)[0m "keep_optim_only_latest": true,
+[36m(Runner pid=2031595)[0m "save_checkpoint_path": "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo",
+[36m(Runner pid=2031595)[0m "load_checkpoint_path": null,
+[36m(Runner pid=2031595)[0m "ray_timeline": null,
+[36m(Runner pid=2031595)[0m "find_last_checkpoint": false,
+[36m(Runner pid=2031595)[0m "keep_best_train_ckpt": false,
+[36m(Runner pid=2031595)[0m "best_train_metric_key": "reward/overall",
+[36m(Runner pid=2031595)[0m "best_train_smooth_window": 5,
+[36m(Runner pid=2031595)[0m "best_train_min_step": 10
+[36m(Runner pid=2031595)[0m }
+[36m(Runner pid=2031595)[0m }
+[36m(Runner pid=2031595)[0m [TaskGroupedBatchSampler] 15 groups: image_sequence_mc_answer_only(10304), obj_appearance_order(64), object_abs_distance(1344), object_counting(960), object_rel_direction(448), object_rel_distance(640), object_size_estimation(1408), room_size_estimation(960), route_planning(960), segmentation(12032), spatial grounding(7040), spatial-temporal grounding(9536), temporal grounding(20096), tracking(13952), video_qa_mc(20288) | batch_size=8 | total_batches=12504
+[36m(Runner pid=2031595)[0m Size of train dataloader: 12504
+[36m(Runner pid=2031595)[0m Size of val dataloader: 32
+[36m(Runner pid=2031595)[0m KL is disabled, no KL metrics will be logged. Please set `kl_coef=0` to log KL metrics.[36m(pid=2033394)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+[36m(pid=2033394)[0m warnings.warn(
+[36m(pid=2033896)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+[36m(pid=2033896)[0m warnings.warn(
+[36m(pid=2033897)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+[36m(pid=2033897)[0m warnings.warn(
+[36m(WorkerDict pid=2033898)[0m [W825 18:00:56.039186514 ProcessGroupNCCL.cpp:929] Warning: TORCH_NCCL_AVOID_RECORD_STREAMS is the default now, this environment variable is thus deprecated. (function operator())
+[36m(pid=2033901)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.[32m [repeated 5x across cluster] (Ray deduplicates logs by default. Set RAY_DEDUP_LOGS=0 to disable log deduplication, or see https://docs.ray.io/en/master/ray-observability/user-guides/configure-logging.html#log-deduplication for more options.)[0m
+[36m(pid=2033901)[0m warnings.warn([32m [repeated 5x across cluster][0m
+[36m(WorkerDict pid=2033898)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.
+[36m(WorkerDict pid=2033898)[0m `torch_dtype` is deprecated! Use `dtype` instead!
+[36m(WorkerDict pid=2033899)[0m [W825 18:00:58.504154548 ProcessGroupNCCL.cpp:929] Warning: TORCH_NCCL_AVOID_RECORD_STREAMS is the default now, this environment variable is thus deprecated. (function operator())[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033898)[0m The fast path is not available because one of the required library is not installed. Falling back to torch implementation. To install follow https://github.com/fla-org/flash-linear-attention#installation and https://github.com/Dao-AILab/causal-conv1d
+[36m(WorkerDict pid=2033898)[0m [rank3]:[W825 18:01:02.812976524 ProcessGroupNCCL.cpp:5138] Guessing device ID based on global rank. This can cause a hang if rank to GPU mapping is heterogeneous. You can specify device_id in init_process_group()
+[36m(WorkerDict pid=2033394)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033394)[0m
Loading weights: 0%| | 0/760 [00:00, ?it/s]
Loading weights: 100%|██████████| 760/760 [00:00<00:00, 10448.47it/s]
+[36m(WorkerDict pid=2033394)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/distributed/c10d_logger.py:83: UserWarning: barrier(): using the device under current context. You can specify `device_id` in `init_process_group` to mute this warning.
+[36m(WorkerDict pid=2033394)[0m return func(*args, **kwargs)
+[36m(WorkerDict pid=2033896)[0m `Qwen2VLImageProcessorFast` is deprecated. The `Fast` suffix for image processors has been removed; use `Qwen2VLImageProcessor` instead.
+[36m(WorkerDict pid=2033899)[0m `torch_dtype` is deprecated! Use `dtype` instead![32m [repeated 6x across cluster][0m
+[36m(WorkerDict pid=2033899)[0m The fast path is not available because one of the required library is not installed. Falling back to torch implementation. To install follow https://github.com/fla-org/flash-linear-attention#installation and https://github.com/Dao-AILab/causal-conv1d[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033899)[0m [rank4]:[W825 18:01:05.292398250 ProcessGroupNCCL.cpp:5138] Guessing device ID based on global rank. This can cause a hang if rank to GPU mapping is heterogeneous. You can specify device_id in init_process_group()[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033896)[0m :1241: FutureWarning: The cuda.cudart module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.runtime module instead.
+[36m(WorkerDict pid=2033896)[0m :1241: FutureWarning: The cuda.nvrtc module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.nvrtc module instead.
+[36m(WorkerDict pid=2033394)[0m `Qwen2VLImageProcessorFast` is deprecated. The `Fast` suffix for image processors has been removed; use `Qwen2VLImageProcessor` instead.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033897)[0m 2026-08-25 18:02:00,379 - INFO - autotuner.py:262 - flashinfer.jit: [Autotuner]: Autotuning process starts ...
+[36m(WorkerDict pid=2033902)[0m :1241: FutureWarning: The cuda.cudart module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.runtime module instead.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033902)[0m :1241: FutureWarning: The cuda.nvrtc module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.nvrtc module instead.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033897)[0m 2026-08-25 18:02:00,394 - INFO - autotuner.py:268 - flashinfer.jit: [Autotuner]: Autotuning process ends
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 0%| | 0/51 [00:00, ?it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 2%|▏ | 1/51 [00:00<00:05, 9.37it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 4%|▍ | 2/51 [00:00<00:05, 9.22it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 8%|▊ | 4/51 [00:00<00:04, 10.28it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 12%|█▏ | 6/51 [00:00<00:04, 11.03it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 16%|█▌ | 8/51 [00:00<00:03, 11.47it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 20%|█▉ | 10/51 [00:00<00:03, 11.38it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 24%|██▎ | 12/51 [00:01<00:03, 11.34it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 27%|██▋ | 14/51 [00:01<00:03, 11.36it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 31%|███▏ | 16/51 [00:01<00:02, 11.99it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 35%|███▌ | 18/51 [00:01<00:02, 11.84it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 39%|███▉ | 20/51 [00:01<00:02, 12.37it/s]
+[36m(WorkerDict pid=2033898)[0m 2026-08-25 18:02:00,394 - INFO - autotuner.py:262 - flashinfer.jit: [Autotuner]: Autotuning process starts ...[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033898)[0m 2026-08-25 18:02:00,406 - INFO - autotuner.py:268 - flashinfer.jit: [Autotuner]: Autotuning process ends[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 43%|████▎ | 22/51 [00:01<00:02, 12.30it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 47%|████▋ | 24/51 [00:02<00:02, 12.79it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 51%|█████ | 26/51 [00:02<00:02, 11.79it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 55%|█████▍ | 28/51 [00:02<00:01, 11.50it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 59%|█████▉ | 30/51 [00:02<00:01, 11.95it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 63%|██████▎ | 32/51 [00:02<00:01, 12.85it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 67%|██████▋ | 34/51 [00:02<00:01, 12.62it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 71%|███████ | 36/51 [00:03<00:01, 12.60it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 75%|███████▍ | 38/51 [00:03<00:00, 13.01it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 78%|███████▊ | 40/51 [00:03<00:00, 13.53it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 82%|████████▏ | 42/51 [00:03<00:00, 13.58it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 86%|████████▋ | 44/51 [00:03<00:00, 11.08it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 90%|█████████ | 46/51 [00:03<00:00, 12.19it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 94%|█████████▍| 48/51 [00:03<00:00, 13.01it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 98%|█████████▊| 50/51 [00:04<00:00, 13.66it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 100%|██████████| 51/51 [00:04<00:00, 12.33it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 0%| | 0/51 [00:00, ?it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 2%|▏ | 1/51 [00:00<00:18, 2.72it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 4%|▍ | 2/51 [00:00<00:12, 3.95it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 6%|▌ | 3/51 [00:00<00:09, 5.28it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 8%|▊ | 4/51 [00:00<00:07, 6.05it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 10%|▉ | 5/51 [00:00<00:07, 6.53it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 12%|█▏ | 6/51 [00:01<00:06, 7.14it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 14%|█▎ | 7/51 [00:01<00:05, 7.44it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 18%|█▊ | 9/51 [00:01<00:04, 8.49it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 22%|██▏ | 11/51 [00:01<00:04, 9.59it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 25%|██▌ | 13/51 [00:01<00:03, 10.73it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 29%|██▉ | 15/51 [00:01<00:03, 11.18it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 33%|███▎ | 17/51 [00:01<00:02, 11.96it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 37%|███▋ | 19/51 [00:02<00:02, 12.55it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 41%|████ | 21/51 [00:02<00:02, 13.68it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 45%|████▌ | 23/51 [00:02<00:02, 13.59it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 49%|████▉ | 25/51 [00:02<00:01, 14.38it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 53%|█████▎ | 27/51 [00:02<00:01, 15.27it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 59%|█████▉ | 30/51 [00:02<00:01, 17.49it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 65%|██████▍ | 33/51 [00:02<00:00, 18.60it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 71%|███████ | 36/51 [00:03<00:00, 20.94it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 78%|███████▊ | 40/51 [00:03<00:00, 23.69it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 86%|████████▋ | 44/51 [00:03<00:00, 25.25it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 94%|█████████▍| 48/51 [00:03<00:00, 27.71it/s]
+[36m(WorkerDict pid=2033394)[0m
Capturing CUDA graphs (decode, FULL): 100%|██████████| 51/51 [00:03<00:00, 14.65it/s]
+
+[36m(Runner pid=2031595)[0m [GRPO] Scaled advantages ON: A=(r-mean)/(std+eps).
+[36m(Runner pid=2031595)[0m Total training steps: 1
+[36m(AutoRewardManager pid=2033189)[0m Using reward function `compute_score` from `orarl.rewards`.
+[36m(AutoRewardManager pid=2033189)[0m Reward name: orarl, reward type: batch.
+[36m(WorkerDict pid=2033394)[0m actor will use global batch size 64.
+[36m(AutoRewardManager pid=2033186)[0m Using reward function `compute_score` from `orarl.rewards`.
+[36m(AutoRewardManager pid=2033186)[0m Reward name: orarl, reward type: batch.
+[36m(WorkerDict pid=2033394)[0m Model config: Qwen3_5Config {
+[36m(WorkerDict pid=2033394)[0m "architectures": [
+[36m(WorkerDict pid=2033394)[0m "Qwen3_5ForConditionalGeneration"
+[36m(WorkerDict pid=2033394)[0m ],
+[36m(WorkerDict pid=2033394)[0m "dtype": "bfloat16",
+[36m(WorkerDict pid=2033394)[0m "image_token_id": 248056,
+[36m(WorkerDict pid=2033394)[0m "model_type": "qwen3_5",
+[36m(WorkerDict pid=2033394)[0m "text_config": {
+[36m(WorkerDict pid=2033394)[0m "attention_bias": false,
+[36m(WorkerDict pid=2033394)[0m "attention_dropout": 0.0,
+[36m(WorkerDict pid=2033394)[0m "attn_output_gate": true,
+[36m(WorkerDict pid=2033394)[0m "bos_token_id": null,
+[36m(WorkerDict pid=2033394)[0m "dtype": "bfloat16",
+[36m(WorkerDict pid=2033394)[0m "eos_token_id": 248044,
+[36m(WorkerDict pid=2033394)[0m "full_attention_interval": 4,
+[36m(WorkerDict pid=2033394)[0m "head_dim": 256,
+[36m(WorkerDict pid=2033394)[0m "hidden_act": "silu",
+[36m(WorkerDict pid=2033394)[0m "hidden_size": 4096,
+[36m(WorkerDict pid=2033394)[0m "initializer_range": 0.02,
+[36m(WorkerDict pid=2033394)[0m "intermediate_size": 12288,
+[36m(WorkerDict pid=2033394)[0m "layer_types": [
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "full_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "full_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "full_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "full_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "full_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "full_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "full_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "linear_attention",
+[36m(WorkerDict pid=2033394)[0m "full_attention"
+[36m(WorkerDict pid=2033394)[0m ],
+[36m(WorkerDict pid=2033394)[0m "linear_conv_kernel_dim": 4,
+[36m(WorkerDict pid=2033394)[0m "linear_key_head_dim": 128,
+[36m(WorkerDict pid=2033394)[0m "linear_num_key_heads": 16,
+[36m(WorkerDict pid=2033394)[0m "linear_num_value_heads": 32,
+[36m(WorkerDict pid=2033394)[0m "linear_value_head_dim": 128,
+[36m(WorkerDict pid=2033394)[0m "mamba_ssm_dtype": "float32",
+[36m(WorkerDict pid=2033394)[0m "max_position_embeddings": 262144,
+[36m(WorkerDict pid=2033394)[0m "mlp_only_layers": [],
+[36m(WorkerDict pid=2033394)[0m "model_type": "qwen3_5_text",
+[36m(WorkerDict pid=2033394)[0m "mtp_num_hidden_layers": 0,
+[36m(WorkerDict pid=2033394)[0m "mtp_use_dedicated_embeddings": false,
+[36m(WorkerDict pid=2033394)[0m "num_attention_heads": 16,
+[36m(WorkerDict pid=2033394)[0m "num_hidden_layers": 32,
+[36m(WorkerDict pid=2033394)[0m "num_key_value_heads": 4,
+[36m(WorkerDict pid=2033394)[0m "pad_token_id": null,
+[36m(WorkerDict pid=2033394)[0m "partial_rotary_factor": 0.25,
+[36m(WorkerDict pid=2033394)[0m "rms_norm_eps": 1e-06,
+[36m(WorkerDict pid=2033394)[0m "rope_parameters": {
+[36m(WorkerDict pid=2033394)[0m "mrope_interleaved": true,
+[36m(WorkerDict pid=2033394)[0m "mrope_section": [
+[36m(WorkerDict pid=2033394)[0m 11,
+[36m(WorkerDict pid=2033394)[0m 11,
+[36m(WorkerDict pid=2033394)[0m 10
+[36m(WorkerDict pid=2033394)[0m ],
+[36m(WorkerDict pid=2033394)[0m "partial_rotary_factor": 0.25,
+[36m(WorkerDict pid=2033394)[0m "rope_theta": 10000000,
+[36m(WorkerDict pid=2033394)[0m "rope_type": "default"
+[36m(WorkerDict pid=2033394)[0m },
+[36m(WorkerDict pid=2033394)[0m "tie_word_embeddings": false,
+[36m(WorkerDict pid=2033394)[0m "use_cache": true,
+[36m(WorkerDict pid=2033394)[0m "vocab_size": 248320
+[36m(WorkerDict pid=2033394)[0m },
+[36m(WorkerDict pid=2033394)[0m "tie_word_embeddings": false,
+[36m(WorkerDict pid=2033394)[0m "transformers_version": "5.5.4",
+[36m(WorkerDict pid=2033394)[0m "video_token_id": 248057,
+[36m(WorkerDict pid=2033394)[0m "vision_config": {
+[36m(WorkerDict pid=2033394)[0m "deepstack_visual_indexes": [],
+[36m(WorkerDict pid=2033394)[0m "depth": 27,
+[36m(WorkerDict pid=2033394)[0m "dtype": "bfloat16",
+[36m(WorkerDict pid=2033394)[0m "hidden_act": "gelu_pytorch_tanh",
+[36m(WorkerDict pid=2033394)[0m "hidden_size": 1152,
+[36m(WorkerDict pid=2033394)[0m "in_channels": 3,
+[36m(WorkerDict pid=2033394)[0m "initializer_range": 0.02,
+[36m(WorkerDict pid=2033394)[0m "intermediate_size": 4304,
+[36m(WorkerDict pid=2033394)[0m "model_type": "qwen3_5",
+[36m(WorkerDict pid=2033394)[0m "num_heads": 16,
+[36m(WorkerDict pid=2033394)[0m "num_position_embeddings": 2304,
+[36m(WorkerDict pid=2033394)[0m "out_hidden_size": 4096,
+[36m(WorkerDict pid=2033394)[0m "patch_size": 16,
+[36m(WorkerDict pid=2033394)[0m "spatial_merge_size": 2,
+[36m(WorkerDict pid=2033394)[0m "temporal_patch_size": 2
+[36m(WorkerDict pid=2033394)[0m },
+[36m(WorkerDict pid=2033394)[0m "vision_end_token_id": 248054,
+[36m(WorkerDict pid=2033394)[0m "vision_start_token_id": 248053
+[36m(WorkerDict pid=2033394)[0m }
+[36m(WorkerDict pid=2033394)[0m
+[36m(WorkerDict pid=2033394)[0m Ulysses patch applied!
+[36m(WorkerDict pid=2033394)[0m Vision backbone is set to not trainable.
+[36m(WorkerDict pid=2033394)[0m Qwen3_5ForConditionalGeneration contains 9.41B parameters.
+[36m(WorkerDict pid=2033394)[0m After huggingface model init: 1.17 GB / 95.00 GB.
+[36m(WorkerDict pid=2033394)[0m FSDP wrap policy: functools.partial(, transformer_layer_cls={, }).
+[36m(WorkerDict pid=2033394)[0m After FSDP module init: 15.42 GB / 95.00 GB.
+[36m(WorkerDict pid=2033394)[0m After optimizer init: 15.42 GB / 95.00 GB.
+[36m(WorkerDict pid=2033899)[0m Sampling params: {'max_tokens': 4096, 'detokenize': False, 'logit_bias': {248056: -100, 248057: -100}, 'n': 8, 'temperature': 1.0, 'top_p': 0.85, 'top_k': -1, 'ignore_eos': False}.
+[36m(Runner pid=2031595)[0m Config
+[36m(Runner pid=2031595)[0m algorithm:
+[36m(Runner pid=2031595)[0m adv_estimator: grpo
+[36m(Runner pid=2031595)[0m disable_kl: true
+[36m(Runner pid=2031595)[0m gamma: 1.0
+[36m(Runner pid=2031595)[0m kl_coef: 0.0
+[36m(Runner pid=2031595)[0m kl_horizon: 10000.0
+[36m(Runner pid=2031595)[0m kl_penalty: low_var_kl
+[36m(Runner pid=2031595)[0m kl_target: 0.1
+[36m(Runner pid=2031595)[0m kl_type: fixed
+[36m(Runner pid=2031595)[0m lam: 1.0
+[36m(Runner pid=2031595)[0m name: grpo
+[36m(Runner pid=2031595)[0m scale_rewards: true
+[36m(Runner pid=2031595)[0m use_kl_loss: false
+[36m(Runner pid=2031595)[0m data:
+[36m(Runner pid=2031595)[0m answer_key: answer
+[36m(Runner pid=2031595)[0m dataloader_num_workers: 0
+[36m(Runner pid=2031595)[0m enable_thinking: false
+[36m(Runner pid=2031595)[0m filter_overlong_prompts: false
+[36m(Runner pid=2031595)[0m filter_overlong_prompts_workers: 16
+[36m(Runner pid=2031595)[0m format_prompt: null
+[36m(Runner pid=2031595)[0m group_by_task: true
+[36m(Runner pid=2031595)[0m group_by_task_key: problem_type
+[36m(Runner pid=2031595)[0m image_dir: /
+[36m(Runner pid=2031595)[0m image_key: images
+[36m(Runner pid=2031595)[0m image_max_pixels: 1048576
+[36m(Runner pid=2031595)[0m image_min_pixels: 4096
+[36m(Runner pid=2031595)[0m inline_video_tensors: true
+[36m(Runner pid=2031595)[0m max_pixels: 4194304
+[36m(Runner pid=2031595)[0m max_prompt_length: 24576
+[36m(Runner pid=2031595)[0m max_response_length: 4096
+[36m(Runner pid=2031595)[0m min_pixels: 4096
+[36m(Runner pid=2031595)[0m mini_rollout_batch_size: null
+[36m(Runner pid=2031595)[0m override_chat_template: null
+[36m(Runner pid=2031595)[0m preprocessed_video_dir: null
+[36m(Runner pid=2031595)[0m prompt_key: problem
+[36m(Runner pid=2031595)[0m response_prefix: ''
+[36m(Runner pid=2031595)[0m rollout_batch_size: 8
+[36m(Runner pid=2031595)[0m seed: 42
+[36m(Runner pid=2031595)[0m shuffle: true
+[36m(Runner pid=2031595)[0m train_files: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl
+[36m(Runner pid=2031595)[0m use_preprocessed_videos: false
+[36m(Runner pid=2031595)[0m val_batch_size: 16
+[36m(Runner pid=2031595)[0m val_files: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl
+[36m(Runner pid=2031595)[0m val_preprocessed_video_dir: null
+[36m(Runner pid=2031595)[0m val_video_fps: 2.0
+[36m(Runner pid=2031595)[0m val_video_max_frames: 128
+[36m(Runner pid=2031595)[0m val_video_max_pixels: 786432
+[36m(Runner pid=2031595)[0m val_video_min_pixels: 4096
+[36m(Runner pid=2031595)[0m val_video_source_mode: realtime_only
+[36m(Runner pid=2031595)[0m val_video_total_pixels: 8388608
+[36m(Runner pid=2031595)[0m video_fps: 2.0
+[36m(Runner pid=2031595)[0m video_key: videos
+[36m(Runner pid=2031595)[0m video_max_frames: 128
+[36m(Runner pid=2031595)[0m video_max_pixels: 786432
+[36m(Runner pid=2031595)[0m video_min_pixels: 4096
+[36m(Runner pid=2031595)[0m video_source_mode: realtime_only
+[36m(Runner pid=2031595)[0m video_total_pixels: 8388608
+[36m(Runner pid=2031595)[0m trainer:
+[36m(Runner pid=2031595)[0m best_train_metric_key: reward/overall
+[36m(Runner pid=2031595)[0m best_train_min_step: 10
+[36m(Runner pid=2031595)[0m best_train_smooth_window: 5
+[36m(Runner pid=2031595)[0m critic_warmup: 0
+[36m(Runner pid=2031595)[0m experiment_name: smoke-grpo-9b
+[36m(Runner pid=2031595)[0m find_last_checkpoint: false
+[36m(Runner pid=2031595)[0m keep_best_train_ckpt: false
+[36m(Runner pid=2031595)[0m keep_optim_only_latest: true
+[36m(Runner pid=2031595)[0m load_checkpoint_path: null
+[36m(Runner pid=2031595)[0m logger:
+[36m(Runner pid=2031595)[0m - console
+[36m(Runner pid=2031595)[0m max_steps: 1
+[36m(Runner pid=2031595)[0m n_gpus_per_node: 8
+[36m(Runner pid=2031595)[0m nnodes: 1
+[36m(Runner pid=2031595)[0m project_name: OraRL
+[36m(Runner pid=2031595)[0m ray_timeline: null
+[36m(Runner pid=2031595)[0m save_checkpoint_path: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo
+[36m(Runner pid=2031595)[0m save_freq: 1
+[36m(Runner pid=2031595)[0m save_limit: 3
+[36m(Runner pid=2031595)[0m save_model_only: false
+[36m(Runner pid=2031595)[0m total_epochs: 1
+[36m(Runner pid=2031595)[0m val_before_train: false
+[36m(Runner pid=2031595)[0m val_freq: -1
+[36m(Runner pid=2031595)[0m val_generations_to_log: 0
+[36m(Runner pid=2031595)[0m val_only: false
+[36m(Runner pid=2031595)[0m worker:
+[36m(Runner pid=2031595)[0m actor:
+[36m(Runner pid=2031595)[0m clip_ratio_dual: 3.0
+[36m(Runner pid=2031595)[0m clip_ratio_high: 0.2
+[36m(Runner pid=2031595)[0m clip_ratio_low: 0.2
+[36m(Runner pid=2031595)[0m disable_kl: true
+[36m(Runner pid=2031595)[0m dynamic_batching: true
+[36m(Runner pid=2031595)[0m fsdp:
+[36m(Runner pid=2031595)[0m enable_cpu_offload: false
+[36m(Runner pid=2031595)[0m enable_full_shard: true
+[36m(Runner pid=2031595)[0m enable_rank0_init: true
+[36m(Runner pid=2031595)[0m fsdp_size: -1
+[36m(Runner pid=2031595)[0m mp_buffer_dtype: fp32
+[36m(Runner pid=2031595)[0m mp_param_dtype: bf16
+[36m(Runner pid=2031595)[0m mp_reduce_dtype: fp32
+[36m(Runner pid=2031595)[0m torch_dtype: bf16
+[36m(Runner pid=2031595)[0m use_orig_params: false
+[36m(Runner pid=2031595)[0m global_batch_size: 8
+[36m(Runner pid=2031595)[0m global_batch_size_per_device: -1
+[36m(Runner pid=2031595)[0m kl_coef: 0.0
+[36m(Runner pid=2031595)[0m kl_penalty: low_var_kl
+[36m(Runner pid=2031595)[0m loss_avg_mode: token
+[36m(Runner pid=2031595)[0m loss_type: default
+[36m(Runner pid=2031595)[0m max_grad_norm: 1.0
+[36m(Runner pid=2031595)[0m max_token_len_per_gpu: 32768
+[36m(Runner pid=2031595)[0m micro_batch_size_per_device_for_experience: 1
+[36m(Runner pid=2031595)[0m micro_batch_size_per_device_for_update: 1
+[36m(Runner pid=2031595)[0m model:
+[36m(Runner pid=2031595)[0m enable_gradient_checkpointing: true
+[36m(Runner pid=2031595)[0m freeze_vision_tower: true
+[36m(Runner pid=2031595)[0m model_path: /apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B
+[36m(Runner pid=2031595)[0m override_config: {}
+[36m(Runner pid=2031595)[0m tokenizer_path: /apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B
+[36m(Runner pid=2031595)[0m train_vision_merger: false
+[36m(Runner pid=2031595)[0m trust_remote_code: false
+[36m(Runner pid=2031595)[0m offload:
+[36m(Runner pid=2031595)[0m offload_optimizer: false
+[36m(Runner pid=2031595)[0m offload_params: false
+[36m(Runner pid=2031595)[0m optim:
+[36m(Runner pid=2031595)[0m betas:
+[36m(Runner pid=2031595)[0m - 0.9
+[36m(Runner pid=2031595)[0m - 0.999
+[36m(Runner pid=2031595)[0m lr: 1.0e-06
+[36m(Runner pid=2031595)[0m lr_scheduler_type: constant
+[36m(Runner pid=2031595)[0m lr_warmup_ratio: 0.0
+[36m(Runner pid=2031595)[0m lr_warmup_steps: null
+[36m(Runner pid=2031595)[0m min_lr_ratio: null
+[36m(Runner pid=2031595)[0m strategy: adamw
+[36m(Runner pid=2031595)[0m training_steps: 1
+[36m(Runner pid=2031595)[0m weight_decay: 0.0
+[36m(Runner pid=2031595)[0m padding_free: true
+[36m(Runner pid=2031595)[0m ppo_epochs: 1
+[36m(Runner pid=2031595)[0m strategy: fsdp
+[36m(Runner pid=2031595)[0m ulysses_size: 1
+[36m(Runner pid=2031595)[0m use_kl_loss: false
+[36m(Runner pid=2031595)[0m use_torch_compile: false
+[36m(Runner pid=2031595)[0m critic:
+[36m(Runner pid=2031595)[0m cliprange_value: 0.5
+[36m(Runner pid=2031595)[0m dynamic_batching: true
+[36m(Runner pid=2031595)[0m fsdp:
+[36m(Runner pid=2031595)[0m enable_cpu_offload: false
+[36m(Runner pid=2031595)[0m enable_full_shard: true
+[36m(Runner pid=2031595)[0m enable_rank0_init: true
+[36m(Runner pid=2031595)[0m fsdp_size: -1
+[36m(Runner pid=2031595)[0m mp_buffer_dtype: fp32
+[36m(Runner pid=2031595)[0m mp_param_dtype: bf16
+[36m(Runner pid=2031595)[0m mp_reduce_dtype: fp32
+[36m(Runner pid=2031595)[0m torch_dtype: null
+[36m(Runner pid=2031595)[0m use_orig_params: false
+[36m(Runner pid=2031595)[0m global_batch_size: 256
+[36m(Runner pid=2031595)[0m global_batch_size_per_device: -1
+[36m(Runner pid=2031595)[0m loss_avg_mode: token
+[36m(Runner pid=2031595)[0m max_grad_norm: 1.0
+[36m(Runner pid=2031595)[0m max_token_len_per_gpu: null
+[36m(Runner pid=2031595)[0m micro_batch_size_per_device_for_experience: 16
+[36m(Runner pid=2031595)[0m micro_batch_size_per_device_for_update: 4
+[36m(Runner pid=2031595)[0m model:
+[36m(Runner pid=2031595)[0m enable_gradient_checkpointing: true
+[36m(Runner pid=2031595)[0m freeze_vision_tower: false
+[36m(Runner pid=2031595)[0m model_path: null
+[36m(Runner pid=2031595)[0m override_config: {}
+[36m(Runner pid=2031595)[0m tokenizer_path: null
+[36m(Runner pid=2031595)[0m train_vision_merger: false
+[36m(Runner pid=2031595)[0m trust_remote_code: true
+[36m(Runner pid=2031595)[0m offload:
+[36m(Runner pid=2031595)[0m offload_optimizer: false
+[36m(Runner pid=2031595)[0m offload_params: false
+[36m(Runner pid=2031595)[0m optim:
+[36m(Runner pid=2031595)[0m betas:
+[36m(Runner pid=2031595)[0m - 0.9
+[36m(Runner pid=2031595)[0m - 0.999
+[36m(Runner pid=2031595)[0m lr: 1.0e-06
+[36m(Runner pid=2031595)[0m lr_scheduler_type: constant
+[36m(Runner pid=2031595)[0m lr_warmup_ratio: 0.0
+[36m(Runner pid=2031595)[0m lr_warmup_steps: null
+[36m(Runner pid=2031595)[0m min_lr_ratio: null
+[36m(Runner pid=2031595)[0m strategy: adamw
+[36m(Runner pid=2031595)[0m training_steps: 1
+[36m(Runner pid=2031595)[0m weight_decay: 0.01
+[36m(Runner pid=2031595)[0m padding_free: false
+[36m(Runner pid=2031595)[0m ppo_epochs: 1
+[36m(Runner pid=2031595)[0m strategy: fsdp
+[36m(Runner pid=2031595)[0m ulysses_size: 1
+[36m(Runner pid=2031595)[0m hybrid_engine: true
+[36m(Runner pid=2031595)[0m ref:
+[36m(Runner pid=2031595)[0m dynamic_batching: true
+[36m(Runner pid=2031595)[0m fsdp:
+[36m(Runner pid=2031595)[0m enable_cpu_offload: false
+[36m(Runner pid=2031595)[0m enable_full_shard: true
+[36m(Runner pid=2031595)[0m enable_rank0_init: true
+[36m(Runner pid=2031595)[0m fsdp_size: -1
+[36m(Runner pid=2031595)[0m mp_buffer_dtype: fp32
+[36m(Runner pid=2031595)[0m mp_param_dtype: bf16
+[36m(Runner pid=2031595)[0m mp_reduce_dtype: fp32
+[36m(Runner pid=2031595)[0m torch_dtype: bf16
+[36m(Runner pid=2031595)[0m use_orig_params: false
+[36m(Runner pid=2031595)[0m max_token_len_per_gpu: 32768
+[36m(Runner pid=2031595)[0m micro_batch_size_per_device_for_experience: 1
+[36m(Runner pid=2031595)[0m offload:
+[36m(Runner pid=2031595)[0m offload_optimizer: false
+[36m(Runner pid=2031595)[0m offload_params: false
+[36m(Runner pid=2031595)[0m padding_free: true
+[36m(Runner pid=2031595)[0m strategy: fsdp
+[36m(Runner pid=2031595)[0m ulysses_size: 1
+[36m(Runner pid=2031595)[0m use_torch_compile: false
+[36m(Runner pid=2031595)[0m reward:
+[36m(Runner pid=2031595)[0m num_cpus: 1
+[36m(Runner pid=2031595)[0m reward_function: orarl.rewards
+[36m(Runner pid=2031595)[0m reward_function_is_module: true
+[36m(Runner pid=2031595)[0m reward_function_kwargs: {}
+[36m(Runner pid=2031595)[0m reward_function_name: compute_score
+[36m(Runner pid=2031595)[0m skip_special_tokens: true
+[36m(Runner pid=2031595)[0m rollout:
+[36m(Runner pid=2031595)[0m calculate_log_probs: false
+[36m(Runner pid=2031595)[0m collect_seq_logprob_for_filter: false
+[36m(Runner pid=2031595)[0m disable_log_stats: true
+[36m(Runner pid=2031595)[0m disable_tqdm: true
+[36m(Runner pid=2031595)[0m dtype: bf16
+[36m(Runner pid=2031595)[0m enable_chunked_prefill: true
+[36m(Runner pid=2031595)[0m enforce_eager: false
+[36m(Runner pid=2031595)[0m gpu_memory_utilization: 0.45
+[36m(Runner pid=2031595)[0m ignore_eos: false
+[36m(Runner pid=2031595)[0m kv_cache_dtype: auto
+[36m(Runner pid=2031595)[0m limit_images: 10
+[36m(Runner pid=2031595)[0m max_model_len: null
+[36m(Runner pid=2031595)[0m max_num_batched_tokens: 32768
+[36m(Runner pid=2031595)[0m n: 8
+[36m(Runner pid=2031595)[0m name: vllm
+[36m(Runner pid=2031595)[0m prompt_length: 24576
+[36m(Runner pid=2031595)[0m response_length: 4096
+[36m(Runner pid=2031595)[0m seed: 42
+[36m(Runner pid=2031595)[0m temperature: 1.0
+[36m(Runner pid=2031595)[0m tensor_parallel_size: 1
+[36m(Runner pid=2031595)[0m top_k: -1
+[36m(Runner pid=2031595)[0m top_p: 0.85
+[36m(Runner pid=2031595)[0m trust_remote_code: false
+[36m(Runner pid=2031595)[0m val_override_config:
+[36m(Runner pid=2031595)[0m n: 1
+[36m(Runner pid=2031595)[0m temperature: 0.7
+[36m(Runner pid=2031595)[0m top_p: 0.95
+[36m(Runner pid=2031595)[0m
+
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:00, ?it/s]
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:00, ?it/s]
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:00, ?it/s]
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:01, ?it/s]
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:02, ?it/s]
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:02, ?it/s]
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:02, ?it/s]
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:22, ?it/s]
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:22, ?it/s]
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [00:22, ?it/s][36m(WorkerDict pid=2033394)[0m After vllm init: 7.01 GB / 95.00 GB.
+[36m(WorkerDict pid=2033394)[0m Before vllm wake up in sharding manager: 7.01 GB / 95.00 GB.
+[36m(WorkerDict pid=2033394)[0m After gather model weights in sharding manager: 30.92 GB / 95.00 GB.
+[36m(WorkerDict pid=2033394)[0m After sync model weights in sharding manager: 24.69 GB / 95.00 GB.
+[36m(WorkerDict pid=2033394)[0m After vllm wake up in sharding manager: 45.04 GB / 95.00 GB.
+[36m(Runner pid=2031595)[0m Start generating batch...
+[36m(WorkerDict pid=2033394)[0m Before vllm offload in sharding manager: 50.43 GB / 95.00 GB.
+[36m(WorkerDict pid=2033902)[0m Sampling params: {'max_tokens': 4096, 'detokenize': False, 'logit_bias': {248056: -100, 248057: -100}, 'n': 8, 'temperature': 1.0, 'top_p': 0.85, 'top_k': -1, 'ignore_eos': False}.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033394)[0m After vllm offload in sharding manager: 7.40 GB / 95.00 GB.
+
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 0%| | 0.00/2.00 [00:00, ?it/s][A[A
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [00:14<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [00:14<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 0%| | 0.00/1.00 [00:00, ?it/s][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 0%| | 0.00/2.00 [00:00, ?it/s][A[A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [01:22<00:00, 41.2s/it][A[A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [01:22<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A[36m(WorkerDict pid=2033394)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/distributed/c10d_logger.py:83: UserWarning: barrier(): using the device under current context. You can specify `device_id` in `init_process_group` to mute this warning.
+
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [02:35, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:05<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [01:44<00:00, 105s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [01:44<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A[36m(WorkerDict pid=2033394)[0m return func(*args, **kwargs)
+
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [02:35, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:05<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [01:44<00:00, 105s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [01:44<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [02:43, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:12<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [01:52<00:00, 112s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [01:52<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [02:43, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:12<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [01:52<00:00, 112s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [01:52<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [02:43, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:12<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [01:52<00:00, 112s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [01:52<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A[36m(WorkerDict pid=2033394)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/distributed/c10d_logger.py:83: UserWarning: barrier(): using the device under current context. You can specify `device_id` in `init_process_group` to mute this warning.
+
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:32, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:02<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:41<00:00, 222s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:41<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A[36m(WorkerDict pid=2033394)[0m return func(*args, **kwargs)
+
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:32, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:02<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:41<00:00, 222s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:41<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 0%| | 0.00/1.00 [04:34, ?it/s]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
[2m[36m(pid=2031595) [0mRunning step: 100%|██████████| 1.00/1.00 [04:34<00:00, 275s/it]
[2m[36m(pid=2031595) [0mRunning step: 100%|██████████| 1.00/1.00 [04:34<00:00, 275s/it]
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 100%|██████████| 1.00/1.00 [04:34<00:00, 275s/it]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 100%|██████████| 1.00/1.00 [04:34<00:00, 275s/it]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2031595) [0mRunning step: 100%|██████████| 1.00/1.00 [04:34<00:00, 275s/it]
+
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:04<00:00, 7.28s/it][A[A
+
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:43<00:00, 224s/it][A[A
+
+
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:43<00:00, 41.2s/it][A[A[A[36m(WorkerDict pid=2033394)[0m [rank-0]: Saving model to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_0.pt.
+[36m(WorkerDict pid=2033394)[0m [rank-0]: Saving optimizer to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_0.pt.
+[36m(WorkerDict pid=2033394)[0m [rank-0]: Saving extra_state to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_0.pt.
+[36m(Runner pid=2031595)[0m [TRAIN STEP] 1/1 step_s=274.7 elapsed=4m34s eta=0m00s
+[36m(Runner pid=2031595)[0m Step 1
+[36m(Runner pid=2031595)[0m actor:
+[36m(Runner pid=2031595)[0m backward_rows: 64.0
+[36m(Runner pid=2031595)[0m backward_rows_per_prompt: 8.0
+[36m(Runner pid=2031595)[0m entropy_loss: 0.112
+[36m(Runner pid=2031595)[0m grad_norm: 7.625
+[36m(Runner pid=2031595)[0m lr: 1.0e-06
+[36m(Runner pid=2031595)[0m pg_clipfrac_higher: 0.0
+[36m(Runner pid=2031595)[0m pg_clipfrac_lower: 0.0
+[36m(Runner pid=2031595)[0m pg_loss: 3.725290298461914e-09
+[36m(Runner pid=2031595)[0m ppo_kl: 0.0
+[36m(Runner pid=2031595)[0m critic:
+[36m(Runner pid=2031595)[0m advantages:
+[36m(Runner pid=2031595)[0m max: 1.62
+[36m(Runner pid=2031595)[0m mean: -4.789658891724002e-09
+[36m(Runner pid=2031595)[0m min: -1.62
+[36m(Runner pid=2031595)[0m std: 0.74
+[36m(Runner pid=2031595)[0m returns:
+[36m(Runner pid=2031595)[0m max: 1.62
+[36m(Runner pid=2031595)[0m mean: -4.789658891724002e-09
+[36m(Runner pid=2031595)[0m min: -1.62
+[36m(Runner pid=2031595)[0m rewards:
+[36m(Runner pid=2031595)[0m max: 1.0
+[36m(Runner pid=2031595)[0m mean: 0.578
+[36m(Runner pid=2031595)[0m min: 0.0
+[36m(Runner pid=2031595)[0m std: 0.494
+[36m(Runner pid=2031595)[0m score:
+[36m(Runner pid=2031595)[0m max: 1.0
+[36m(Runner pid=2031595)[0m mean: 0.578
+[36m(Runner pid=2031595)[0m min: 0.0
+[36m(Runner pid=2031595)[0m std: 0.494
+[36m(Runner pid=2031595)[0m var: 0.244
+[36m(Runner pid=2031595)[0m global_seqlen:
+[36m(Runner pid=2031595)[0m balanced_max: 54644
+[36m(Runner pid=2031595)[0m balanced_min: 54644
+[36m(Runner pid=2031595)[0m max: 68488
+[36m(Runner pid=2031595)[0m mean: 54644.0
+[36m(Runner pid=2031595)[0m min: 18296
+[36m(Runner pid=2031595)[0m minmax_diff: 50192
+[36m(Runner pid=2031595)[0m perf:
+[36m(Runner pid=2031595)[0m cpu_memory_used_gb: 1091.043
+[36m(Runner pid=2031595)[0m max_memory_allocated_gb: 69.333
+[36m(Runner pid=2031595)[0m max_memory_reserved_gb: 83.51
+[36m(Runner pid=2031595)[0m mfu_actor: 0.217
+[36m(Runner pid=2031595)[0m throughput: 198.904
+[36m(Runner pid=2031595)[0m time_per_step: 274.726
+[36m(Runner pid=2031595)[0m total_num_tokens: 437152
+[36m(Runner pid=2031595)[0m prompt_length:
+[36m(Runner pid=2031595)[0m clip_ratio: 0.0
+[36m(Runner pid=2031595)[0m max: 8554.0
+[36m(Runner pid=2031595)[0m mean: 6823.5
+[36m(Runner pid=2031595)[0m min: 2280.0
+[36m(Runner pid=2031595)[0m response_length:
+[36m(Runner pid=2031595)[0m clip_ratio: 0.0
+[36m(Runner pid=2031595)[0m max: 7.0
+[36m(Runner pid=2031595)[0m mean: 7.0
+[36m(Runner pid=2031595)[0m min: 7.0
+[36m(Runner pid=2031595)[0m reward:
+[36m(Runner pid=2031595)[0m accuracy: 0.578
+[36m(Runner pid=2031595)[0m format: 1.0
+[36m(Runner pid=2031595)[0m overall: 0.578
+[36m(Runner pid=2031595)[0m rollout:
+[36m(Runner pid=2031595)[0m all_identical_group_fraction: 0.375
+[36m(Runner pid=2031595)[0m exact_unique_fraction: 0.281
+[36m(Runner pid=2031595)[0m pairwise_token_agreement_mean: 0.943
+[36m(Runner pid=2031595)[0m response_length_std_mean: 0.0
+[36m(Runner pid=2031595)[0m reward_group_std_mean: 0.29
+[36m(Runner pid=2031595)[0m reward_unique_fraction: 0.203
+[36m(Runner pid=2031595)[0m zero_reward_std_group_fraction: 0.375
+[36m(Runner pid=2031595)[0m timing_per_token_ms:
+[36m(Runner pid=2031595)[0m adv: 7.939586091993053e-06
+[36m(Runner pid=2031595)[0m gen: 51.144
+[36m(Runner pid=2031595)[0m old: 0.064
+[36m(Runner pid=2031595)[0m reward: 0.008
+[36m(Runner pid=2031595)[0m update_actor: 0.239
+[36m(Runner pid=2031595)[0m timing_s:
+[36m(Runner pid=2031595)[0m adv: 0.003
+[36m(Runner pid=2031595)[0m gen: 22.913
+[36m(Runner pid=2031595)[0m old: 28.067
+[36m(Runner pid=2031595)[0m reward: 0.004
+[36m(Runner pid=2031595)[0m save_checkpoint: 119.155
+[36m(Runner pid=2031595)[0m step: 274.726
+[36m(Runner pid=2031595)[0m update_actor: 104.535
+[36m(Runner pid=2031595)[0m
+[36m(WorkerDict pid=2033899)[0m [rank-4]: Saving model to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/model_world_size_8_rank_4.pt.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033899)[0m [rank-4]: Saving optimizer to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/optim_world_size_8_rank_4.pt.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2033899)[0m [rank-4]: Saving extra_state to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo/global_step_1/actor/extra_state_world_size_8_rank_4.pt.[32m [repeated 7x across cluster][0m
+
[2m[36m(pid=2031595) [0mRunning step: 100%|██████████| 1.00/1.00 [04:39<00:00, 280s/it]
+
[2m[36m(pid=2033394) [0mCompute log probs: 100%|██████████| 2.00/2.00 [04:09<00:00, 125s/it]
+
[2m[36m(pid=2033394) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [03:48<00:00, 229s/it]
+
[2m[36m(pid=2033394) [0mUpdate policy: 100%|██████████| 2.00/2.00 [03:48<00:00, 114s/it]
+method=grpo mode=run
+/data/miniconda3/envs/orarl/bin/python -m verl.trainer.main config=/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/configs/grpo_9b.yaml trainer.max_steps=1 trainer.save_freq=1 trainer.val_before_train=false trainer.experiment_name=smoke-grpo-9b data.rollout_batch_size=8 worker.actor.global_batch_size=8 worker.actor.model.model_path=/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B worker.actor.model.tokenizer_path=/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B data.train_files=/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl data.val_files=/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl trainer.save_checkpoint_path=/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo trainer.nnodes=1 trainer.n_gpus_per_node=8
+checkpoint OK: grpo -> /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo/global_step_1
+
+=== orarl smoke (1 update(s)) ===
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+ warnings.warn(
+2026-08-25 18:07:46,220 INFO worker.py:2004 -- Started a local Ray instance. View the dashboard at [1m[32mhttp://127.0.0.1:8265 [39m[22m
+/data/miniconda3/envs/orarl/lib/python3.11/site-packages/ray/_private/worker.py:2052: FutureWarning: Tip: In future versions of Ray, Ray will no longer override accelerator visible devices env var if num_gpus=0 or num_gpus=None (default). To enable this behavior and turn off this error message, set RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO=0
+ warnings.warn(
+[36m(pid=2088024)[0m W0825 18:08:01.584000 2088024 site-packages/torch/utils/cpp_extension.py:118] No CUDA runtime is found, using CUDA_HOME='/data/miniconda3/envs/orarl'
+[36m(Runner pid=2088024)[0m {
+[36m(Runner pid=2088024)[0m "data": {
+[36m(Runner pid=2088024)[0m "train_files": "/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl",
+[36m(Runner pid=2088024)[0m "val_files": "/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl",
+[36m(Runner pid=2088024)[0m "prompt_key": "problem",
+[36m(Runner pid=2088024)[0m "answer_key": "answer",
+[36m(Runner pid=2088024)[0m "image_key": "images",
+[36m(Runner pid=2088024)[0m "video_key": "videos",
+[36m(Runner pid=2088024)[0m "image_dir": "/",
+[36m(Runner pid=2088024)[0m "video_fps": 2.0,
+[36m(Runner pid=2088024)[0m "video_max_frames": 128,
+[36m(Runner pid=2088024)[0m "max_prompt_length": 24576,
+[36m(Runner pid=2088024)[0m "max_response_length": 4096,
+[36m(Runner pid=2088024)[0m "rollout_batch_size": 8,
+[36m(Runner pid=2088024)[0m "mini_rollout_batch_size": null,
+[36m(Runner pid=2088024)[0m "val_batch_size": 16,
+[36m(Runner pid=2088024)[0m "format_prompt": null,
+[36m(Runner pid=2088024)[0m "override_chat_template": null,
+[36m(Runner pid=2088024)[0m "enable_thinking": false,
+[36m(Runner pid=2088024)[0m "response_prefix": "",
+[36m(Runner pid=2088024)[0m "shuffle": true,
+[36m(Runner pid=2088024)[0m "seed": 42,
+[36m(Runner pid=2088024)[0m "min_pixels": 4096,
+[36m(Runner pid=2088024)[0m "max_pixels": 4194304,
+[36m(Runner pid=2088024)[0m "image_min_pixels": 4096,
+[36m(Runner pid=2088024)[0m "image_max_pixels": 1048576,
+[36m(Runner pid=2088024)[0m "video_min_pixels": 4096,
+[36m(Runner pid=2088024)[0m "video_max_pixels": 786432,
+[36m(Runner pid=2088024)[0m "video_total_pixels": 8388608,
+[36m(Runner pid=2088024)[0m "val_video_fps": 2.0,
+[36m(Runner pid=2088024)[0m "val_video_max_frames": 128,
+[36m(Runner pid=2088024)[0m "val_video_min_pixels": 4096,
+[36m(Runner pid=2088024)[0m "val_video_max_pixels": 786432,
+[36m(Runner pid=2088024)[0m "val_video_total_pixels": 8388608,
+[36m(Runner pid=2088024)[0m "filter_overlong_prompts": false,
+[36m(Runner pid=2088024)[0m "filter_overlong_prompts_workers": 16,
+[36m(Runner pid=2088024)[0m "use_preprocessed_videos": false,
+[36m(Runner pid=2088024)[0m "video_source_mode": "realtime_only",
+[36m(Runner pid=2088024)[0m "preprocessed_video_dir": null,
+[36m(Runner pid=2088024)[0m "val_preprocessed_video_dir": null,
+[36m(Runner pid=2088024)[0m "val_video_source_mode": "realtime_only",
+[36m(Runner pid=2088024)[0m "inline_video_tensors": true,
+[36m(Runner pid=2088024)[0m "group_by_task": true,
+[36m(Runner pid=2088024)[0m "group_by_task_key": "problem_type",
+[36m(Runner pid=2088024)[0m "dataloader_num_workers": 0
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "worker": {
+[36m(Runner pid=2088024)[0m "hybrid_engine": true,
+[36m(Runner pid=2088024)[0m "actor": {
+[36m(Runner pid=2088024)[0m "strategy": "fsdp",
+[36m(Runner pid=2088024)[0m "global_batch_size": 8,
+[36m(Runner pid=2088024)[0m "micro_batch_size_per_device_for_update": 1,
+[36m(Runner pid=2088024)[0m "micro_batch_size_per_device_for_experience": 1,
+[36m(Runner pid=2088024)[0m "max_grad_norm": 1.0,
+[36m(Runner pid=2088024)[0m "clip_ratio_low": 0.2,
+[36m(Runner pid=2088024)[0m "clip_ratio_high": 0.2,
+[36m(Runner pid=2088024)[0m "clip_ratio_dual": 3.0,
+[36m(Runner pid=2088024)[0m "loss_avg_mode": "token",
+[36m(Runner pid=2088024)[0m "loss_type": "default",
+[36m(Runner pid=2088024)[0m "ppo_epochs": 1,
+[36m(Runner pid=2088024)[0m "padding_free": true,
+[36m(Runner pid=2088024)[0m "dynamic_batching": true,
+[36m(Runner pid=2088024)[0m "max_token_len_per_gpu": 32768,
+[36m(Runner pid=2088024)[0m "ulysses_size": 1,
+[36m(Runner pid=2088024)[0m "use_torch_compile": false,
+[36m(Runner pid=2088024)[0m "model": {
+[36m(Runner pid=2088024)[0m "model_path": "/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B",
+[36m(Runner pid=2088024)[0m "tokenizer_path": "/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B",
+[36m(Runner pid=2088024)[0m "override_config": {},
+[36m(Runner pid=2088024)[0m "enable_gradient_checkpointing": true,
+[36m(Runner pid=2088024)[0m "trust_remote_code": false,
+[36m(Runner pid=2088024)[0m "freeze_vision_tower": true,
+[36m(Runner pid=2088024)[0m "train_vision_merger": false
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "optim": {
+[36m(Runner pid=2088024)[0m "lr": 1e-06,
+[36m(Runner pid=2088024)[0m "betas": [
+[36m(Runner pid=2088024)[0m 0.9,
+[36m(Runner pid=2088024)[0m 0.999
+[36m(Runner pid=2088024)[0m ],
+[36m(Runner pid=2088024)[0m "weight_decay": 0.0,
+[36m(Runner pid=2088024)[0m "strategy": "adamw",
+[36m(Runner pid=2088024)[0m "lr_warmup_ratio": 0.0,
+[36m(Runner pid=2088024)[0m "lr_warmup_steps": null,
+[36m(Runner pid=2088024)[0m "min_lr_ratio": null,
+[36m(Runner pid=2088024)[0m "lr_scheduler_type": "constant",
+[36m(Runner pid=2088024)[0m "training_steps": -1
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "fsdp": {
+[36m(Runner pid=2088024)[0m "enable_full_shard": true,
+[36m(Runner pid=2088024)[0m "enable_cpu_offload": false,
+[36m(Runner pid=2088024)[0m "enable_rank0_init": true,
+[36m(Runner pid=2088024)[0m "use_orig_params": false,
+[36m(Runner pid=2088024)[0m "torch_dtype": "bf16",
+[36m(Runner pid=2088024)[0m "fsdp_size": -1,
+[36m(Runner pid=2088024)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=2088024)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=2088024)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "offload": {
+[36m(Runner pid=2088024)[0m "offload_params": false,
+[36m(Runner pid=2088024)[0m "offload_optimizer": false
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "global_batch_size_per_device": -1,
+[36m(Runner pid=2088024)[0m "disable_kl": true,
+[36m(Runner pid=2088024)[0m "use_kl_loss": false,
+[36m(Runner pid=2088024)[0m "kl_penalty": "low_var_kl",
+[36m(Runner pid=2088024)[0m "kl_coef": 0.0,
+[36m(Runner pid=2088024)[0m "selection_prune_ratio": 0.5
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "critic": {
+[36m(Runner pid=2088024)[0m "strategy": "fsdp",
+[36m(Runner pid=2088024)[0m "global_batch_size": 256,
+[36m(Runner pid=2088024)[0m "micro_batch_size_per_device_for_update": 4,
+[36m(Runner pid=2088024)[0m "micro_batch_size_per_device_for_experience": 16,
+[36m(Runner pid=2088024)[0m "max_grad_norm": 1.0,
+[36m(Runner pid=2088024)[0m "cliprange_value": 0.5,
+[36m(Runner pid=2088024)[0m "loss_avg_mode": "token",
+[36m(Runner pid=2088024)[0m "ppo_epochs": 1,
+[36m(Runner pid=2088024)[0m "padding_free": false,
+[36m(Runner pid=2088024)[0m "dynamic_batching": true,
+[36m(Runner pid=2088024)[0m "max_token_len_per_gpu": null,
+[36m(Runner pid=2088024)[0m "ulysses_size": 1,
+[36m(Runner pid=2088024)[0m "model": {
+[36m(Runner pid=2088024)[0m "model_path": null,
+[36m(Runner pid=2088024)[0m "tokenizer_path": null,
+[36m(Runner pid=2088024)[0m "override_config": {},
+[36m(Runner pid=2088024)[0m "enable_gradient_checkpointing": true,
+[36m(Runner pid=2088024)[0m "trust_remote_code": true,
+[36m(Runner pid=2088024)[0m "freeze_vision_tower": false,
+[36m(Runner pid=2088024)[0m "train_vision_merger": false
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "optim": {
+[36m(Runner pid=2088024)[0m "lr": 1e-06,
+[36m(Runner pid=2088024)[0m "betas": [
+[36m(Runner pid=2088024)[0m 0.9,
+[36m(Runner pid=2088024)[0m 0.999
+[36m(Runner pid=2088024)[0m ],
+[36m(Runner pid=2088024)[0m "weight_decay": 0.01,
+[36m(Runner pid=2088024)[0m "strategy": "adamw",
+[36m(Runner pid=2088024)[0m "lr_warmup_ratio": 0.0,
+[36m(Runner pid=2088024)[0m "lr_warmup_steps": null,
+[36m(Runner pid=2088024)[0m "min_lr_ratio": null,
+[36m(Runner pid=2088024)[0m "lr_scheduler_type": "constant",
+[36m(Runner pid=2088024)[0m "training_steps": -1
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "fsdp": {
+[36m(Runner pid=2088024)[0m "enable_full_shard": true,
+[36m(Runner pid=2088024)[0m "enable_cpu_offload": false,
+[36m(Runner pid=2088024)[0m "enable_rank0_init": true,
+[36m(Runner pid=2088024)[0m "use_orig_params": false,
+[36m(Runner pid=2088024)[0m "torch_dtype": null,
+[36m(Runner pid=2088024)[0m "fsdp_size": -1,
+[36m(Runner pid=2088024)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=2088024)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=2088024)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "offload": {
+[36m(Runner pid=2088024)[0m "offload_params": false,
+[36m(Runner pid=2088024)[0m "offload_optimizer": false
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "global_batch_size_per_device": -1
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "ref": {
+[36m(Runner pid=2088024)[0m "strategy": "fsdp",
+[36m(Runner pid=2088024)[0m "fsdp": {
+[36m(Runner pid=2088024)[0m "enable_full_shard": true,
+[36m(Runner pid=2088024)[0m "enable_cpu_offload": false,
+[36m(Runner pid=2088024)[0m "enable_rank0_init": true,
+[36m(Runner pid=2088024)[0m "use_orig_params": false,
+[36m(Runner pid=2088024)[0m "torch_dtype": "bf16",
+[36m(Runner pid=2088024)[0m "fsdp_size": -1,
+[36m(Runner pid=2088024)[0m "mp_param_dtype": "bf16",
+[36m(Runner pid=2088024)[0m "mp_reduce_dtype": "fp32",
+[36m(Runner pid=2088024)[0m "mp_buffer_dtype": "fp32"
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "offload": {
+[36m(Runner pid=2088024)[0m "offload_params": false,
+[36m(Runner pid=2088024)[0m "offload_optimizer": false
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "micro_batch_size_per_device_for_experience": 1,
+[36m(Runner pid=2088024)[0m "padding_free": true,
+[36m(Runner pid=2088024)[0m "dynamic_batching": true,
+[36m(Runner pid=2088024)[0m "max_token_len_per_gpu": 32768,
+[36m(Runner pid=2088024)[0m "ulysses_size": 1,
+[36m(Runner pid=2088024)[0m "use_torch_compile": false
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "reward": {
+[36m(Runner pid=2088024)[0m "reward_function": "orarl.rewards",
+[36m(Runner pid=2088024)[0m "reward_function_kwargs": {},
+[36m(Runner pid=2088024)[0m "skip_special_tokens": true,
+[36m(Runner pid=2088024)[0m "num_cpus": 1,
+[36m(Runner pid=2088024)[0m "reward_function_name": "compute_score",
+[36m(Runner pid=2088024)[0m "reward_function_is_module": true
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "rollout": {
+[36m(Runner pid=2088024)[0m "name": "vllm",
+[36m(Runner pid=2088024)[0m "n": 8,
+[36m(Runner pid=2088024)[0m "temperature": 1.0,
+[36m(Runner pid=2088024)[0m "top_p": 0.85,
+[36m(Runner pid=2088024)[0m "top_k": -1,
+[36m(Runner pid=2088024)[0m "seed": 42,
+[36m(Runner pid=2088024)[0m "limit_images": 10,
+[36m(Runner pid=2088024)[0m "dtype": "bf16",
+[36m(Runner pid=2088024)[0m "gpu_memory_utilization": 0.45,
+[36m(Runner pid=2088024)[0m "ignore_eos": false,
+[36m(Runner pid=2088024)[0m "enforce_eager": false,
+[36m(Runner pid=2088024)[0m "enable_chunked_prefill": true,
+[36m(Runner pid=2088024)[0m "tensor_parallel_size": 1,
+[36m(Runner pid=2088024)[0m "max_model_len": null,
+[36m(Runner pid=2088024)[0m "max_num_batched_tokens": 32768,
+[36m(Runner pid=2088024)[0m "disable_log_stats": true,
+[36m(Runner pid=2088024)[0m "disable_tqdm": true,
+[36m(Runner pid=2088024)[0m "val_override_config": {
+[36m(Runner pid=2088024)[0m "temperature": 0.7,
+[36m(Runner pid=2088024)[0m "top_p": 0.95,
+[36m(Runner pid=2088024)[0m "n": 1
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "kv_cache_dtype": "auto",
+[36m(Runner pid=2088024)[0m "calculate_log_probs": false,
+[36m(Runner pid=2088024)[0m "collect_seq_logprob_for_filter": false,
+[36m(Runner pid=2088024)[0m "prompt_length": 24576,
+[36m(Runner pid=2088024)[0m "response_length": 4096,
+[36m(Runner pid=2088024)[0m "trust_remote_code": false
+[36m(Runner pid=2088024)[0m }
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "algorithm": {
+[36m(Runner pid=2088024)[0m "name": "orarl",
+[36m(Runner pid=2088024)[0m "gamma": 1.0,
+[36m(Runner pid=2088024)[0m "lam": 1.0,
+[36m(Runner pid=2088024)[0m "adv_estimator": "grpo",
+[36m(Runner pid=2088024)[0m "scale_rewards": false,
+[36m(Runner pid=2088024)[0m "disable_kl": true,
+[36m(Runner pid=2088024)[0m "use_kl_loss": false,
+[36m(Runner pid=2088024)[0m "kl_penalty": "low_var_kl",
+[36m(Runner pid=2088024)[0m "kl_coef": 0.0,
+[36m(Runner pid=2088024)[0m "kl_type": "fixed",
+[36m(Runner pid=2088024)[0m "kl_horizon": 10000.0,
+[36m(Runner pid=2088024)[0m "kl_target": 0.1,
+[36m(Runner pid=2088024)[0m "oracle_injection": true,
+[36m(Runner pid=2088024)[0m "oracle_injection_mode": "append",
+[36m(Runner pid=2088024)[0m "oracle_builder": "orarl.rewards:build_oracle_response_from_ground_truth",
+[36m(Runner pid=2088024)[0m "oracle_replace_index": -1,
+[36m(Runner pid=2088024)[0m "oracle_log_exclude": true,
+[36m(Runner pid=2088024)[0m "directional_gain": true,
+[36m(Runner pid=2088024)[0m "directional_gain_gamma": 0.25,
+[36m(Runner pid=2088024)[0m "directional_gain_positive_only": true,
+[36m(Runner pid=2088024)[0m "directional_gain_recenter": true,
+[36m(Runner pid=2088024)[0m "detached_oracle_advantage": true,
+[36m(Runner pid=2088024)[0m "detached_oracle_advantage_scale": 2.0,
+[36m(Runner pid=2088024)[0m "detached_oracle_use_directional_gain": false,
+[36m(Runner pid=2088024)[0m "detached_oracle_match_best_ratio": 1.2,
+[36m(Runner pid=2088024)[0m "detached_oracle_match_best_min": 0.05,
+[36m(Runner pid=2088024)[0m "detached_oracle_match_best_max": 1.0,
+[36m(Runner pid=2088024)[0m "oracle_reward_gate_beta": 2.0,
+[36m(Runner pid=2088024)[0m "selection_prune_ratio": 0.5,
+[36m(Runner pid=2088024)[0m "selection_keep_oracle": true,
+[36m(Runner pid=2088024)[0m "selection_positive_quota": 1,
+[36m(Runner pid=2088024)[0m "selection_negative_quota": 2,
+[36m(Runner pid=2088024)[0m "selection_strict_sign_balance": true,
+[36m(Runner pid=2088024)[0m "post_selection_recenter": true,
+[36m(Runner pid=2088024)[0m "post_selection_rms_match": true,
+[36m(Runner pid=2088024)[0m "post_selection_rms_min_scale": 0.25
+[36m(Runner pid=2088024)[0m },
+[36m(Runner pid=2088024)[0m "trainer": {
+[36m(Runner pid=2088024)[0m "total_epochs": 1,
+[36m(Runner pid=2088024)[0m "max_steps": 1,
+[36m(Runner pid=2088024)[0m "project_name": "OraRL",
+[36m(Runner pid=2088024)[0m "experiment_name": "smoke-orarl-9b",
+[36m(Runner pid=2088024)[0m "logger": [
+[36m(Runner pid=2088024)[0m "console"
+[36m(Runner pid=2088024)[0m ],
+[36m(Runner pid=2088024)[0m "nnodes": 1,
+[36m(Runner pid=2088024)[0m "n_gpus_per_node": 8,
+[36m(Runner pid=2088024)[0m "critic_warmup": 0,[36m(Runner pid=2088024)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.
+[36m(pid=2089852)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+[36m(pid=2089852)[0m warnings.warn(
+[36m(pid=2090322)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+[36m(pid=2090322)[0m warnings.warn(
+[36m(pid=2090324)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.
+[36m(pid=2090324)[0m warnings.warn(
+[36m(WorkerDict pid=2090328)[0m [W825 18:08:26.116194738 ProcessGroupNCCL.cpp:929] Warning: TORCH_NCCL_AVOID_RECORD_STREAMS is the default now, this environment variable is thus deprecated. (function operator())
+[36m(pid=2090327)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/transformers/utils/import_utils.py:1006: FutureWarning: `is_flash_attn_greater_or_equal_2_10` is deprecated and will be removed in v5.8. Please use `is_flash_attn_greater_or_equal(library_version='2.1.0')` instead if needed.[32m [repeated 5x across cluster] (Ray deduplicates logs by default. Set RAY_DEDUP_LOGS=0 to disable log deduplication, or see https://docs.ray.io/en/master/ray-observability/user-guides/configure-logging.html#log-deduplication for more options.)[0m
+[36m(pid=2090327)[0m warnings.warn([32m [repeated 5x across cluster][0m
+[36m(WorkerDict pid=2090328)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.
+
+[36m(Runner pid=2088024)[0m "val_freq": -1,
+[36m(Runner pid=2088024)[0m "val_before_train": false,
+[36m(Runner pid=2088024)[0m "val_only": false,
+[36m(Runner pid=2088024)[0m "val_generations_to_log": 0,
+[36m(Runner pid=2088024)[0m "save_freq": 1,
+[36m(Runner pid=2088024)[0m "save_limit": 3,
+[36m(Runner pid=2088024)[0m "save_model_only": false,
+[36m(Runner pid=2088024)[0m "keep_optim_only_latest": true,
+[36m(Runner pid=2088024)[0m "save_checkpoint_path": "/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl",
+[36m(Runner pid=2088024)[0m "load_checkpoint_path": null,
+[36m(Runner pid=2088024)[0m "ray_timeline": null,
+[36m(Runner pid=2088024)[0m "find_last_checkpoint": false,
+[36m(Runner pid=2088024)[0m "keep_best_train_ckpt": false,
+[36m(Runner pid=2088024)[0m "best_train_metric_key": "reward/overall",
+[36m(Runner pid=2088024)[0m "best_train_smooth_window": 5,
+[36m(Runner pid=2088024)[0m "best_train_min_step": 10
+[36m(Runner pid=2088024)[0m }
+[36m(Runner pid=2088024)[0m }
+[36m(Runner pid=2088024)[0m [TaskGroupedBatchSampler] 15 groups: image_sequence_mc_answer_only(10304), obj_appearance_order(64), object_abs_distance(1344), object_counting(960), object_rel_direction(448), object_rel_distance(640), object_size_estimation(1408), room_size_estimation(960), route_planning(960), segmentation(12032), spatial grounding(7040), spatial-temporal grounding(9536), temporal grounding(20096), tracking(13952), video_qa_mc(20288) | batch_size=8 | total_batches=12504
+[36m(Runner pid=2088024)[0m Size of train dataloader: 12504
+[36m(Runner pid=2088024)[0m Size of val dataloader: 32
+[36m(Runner pid=2088024)[0m KL is disabled, no KL metrics will be logged. Please set `kl_coef=0` to log KL metrics.
+[36m(Runner pid=2088024)[0m [GRPO] Raw-centered advantages ON: A=r-mean (no std whitening).
+[36m(Runner pid=2088024)[0m [OraRL] enabled | append annotation-as-rollout | directional_gain_gamma=0.25 | detached oracle | strict sign-balanced selection | post-selection moment correction
+[36m(Runner pid=2088024)[0m Total training steps: 1
+[36m(AutoRewardManager pid=2089612)[0m Using reward function `compute_score` from `orarl.rewards`.
+[36m(AutoRewardManager pid=2089612)[0m Reward name: orarl, reward type: batch.
+[36m(WorkerDict pid=2089852)[0m actor OraRL selection active (P=0.5): scaling global_batch_size with k=4 instead of n=8.
+[36m(WorkerDict pid=2089852)[0m actor will use global batch size 32.
+[36m(AutoRewardManager pid=2089614)[0m Using reward function `compute_score` from `orarl.rewards`.
+[36m(AutoRewardManager pid=2089614)[0m Reward name: orarl, reward type: batch.
+[36m(WorkerDict pid=2089852)[0m Model config: Qwen3_5Config {
+[36m(WorkerDict pid=2089852)[0m "architectures": [
+[36m(WorkerDict pid=2089852)[0m "Qwen3_5ForConditionalGeneration"
+[36m(WorkerDict pid=2089852)[0m ],
+[36m(WorkerDict pid=2089852)[0m "dtype": "bfloat16",
+[36m(WorkerDict pid=2089852)[0m "image_token_id": 248056,
+[36m(WorkerDict pid=2089852)[0m "model_type": "qwen3_5",
+[36m(WorkerDict pid=2089852)[0m "text_config": {
+[36m(WorkerDict pid=2089852)[0m "attention_bias": false,
+[36m(WorkerDict pid=2089852)[0m "attention_dropout": 0.0,
+[36m(WorkerDict pid=2089852)[0m "attn_output_gate": true,
+[36m(WorkerDict pid=2089852)[0m "bos_token_id": null,
+[36m(WorkerDict pid=2089852)[0m "dtype": "bfloat16",
+[36m(WorkerDict pid=2089852)[0m "eos_token_id": 248044,
+[36m(WorkerDict pid=2089852)[0m "full_attention_interval": 4,
+[36m(WorkerDict pid=2089852)[0m "head_dim": 256,
+[36m(WorkerDict pid=2089852)[0m "hidden_act": "silu",
+[36m(WorkerDict pid=2089852)[0m "hidden_size": 4096,
+[36m(WorkerDict pid=2089852)[0m "initializer_range": 0.02,
+[36m(WorkerDict pid=2089852)[0m "intermediate_size": 12288,
+[36m(WorkerDict pid=2089852)[0m "layer_types": [
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "full_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "full_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "full_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "full_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "full_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "full_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "full_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "linear_attention",
+[36m(WorkerDict pid=2089852)[0m "full_attention"
+[36m(WorkerDict pid=2089852)[0m ],
+[36m(WorkerDict pid=2089852)[0m "linear_conv_kernel_dim": 4,
+[36m(WorkerDict pid=2089852)[0m "linear_key_head_dim": 128,
+[36m(WorkerDict pid=2089852)[0m "linear_num_key_heads": 16,
+[36m(WorkerDict pid=2089852)[0m "linear_num_value_heads": 32,
+[36m(WorkerDict pid=2089852)[0m "linear_value_head_dim": 128,
+[36m(WorkerDict pid=2089852)[0m "mamba_ssm_dtype": "float32",
+[36m(WorkerDict pid=2089852)[0m "max_position_embeddings": 262144,
+[36m(WorkerDict pid=2089852)[0m "mlp_only_layers": [],
+[36m(WorkerDict pid=2089852)[0m "model_type": "qwen3_5_text",
+[36m(WorkerDict pid=2089852)[0m "mtp_num_hidden_layers": 0,
+[36m(WorkerDict pid=2089852)[0m "mtp_use_dedicated_embeddings": false,
+[36m(WorkerDict pid=2089852)[0m "num_attention_heads": 16,
+[36m(WorkerDict pid=2089852)[0m "num_hidden_layers": 32,
+[36m(WorkerDict pid=2089852)[0m "num_key_value_heads": 4,
+[36m(WorkerDict pid=2089852)[0m "pad_token_id": null,
+[36m(WorkerDict pid=2089852)[0m "partial_rotary_factor": 0.25,
+[36m(WorkerDict pid=2089852)[0m "rms_norm_eps": 1e-06,
+[36m(WorkerDict pid=2089852)[0m "rope_parameters": {
+[36m(WorkerDict pid=2089852)[0m "mrope_interleaved": true,
+[36m(WorkerDict pid=2089852)[0m "mrope_section": [
+[36m(WorkerDict pid=2089852)[0m 11,
+[36m(WorkerDict pid=2089852)[0m 11,
+[36m(WorkerDict pid=2089852)[0m 10
+[36m(WorkerDict pid=2089852)[0m ],
+[36m(WorkerDict pid=2089852)[0m "partial_rotary_factor": 0.25,
+[36m(WorkerDict pid=2089852)[0m "rope_theta": 10000000,
+[36m(WorkerDict pid=2089852)[0m "rope_type": "default"
+[36m(WorkerDict pid=2089852)[0m },
+[36m(WorkerDict pid=2089852)[0m "tie_word_embeddings": false,
+[36m(WorkerDict pid=2089852)[0m "use_cache": true,
+[36m(WorkerDict pid=2089852)[0m "vocab_size": 248320
+[36m(WorkerDict pid=2089852)[0m },
+[36m(WorkerDict pid=2089852)[0m "tie_word_embeddings": false,
+[36m(WorkerDict pid=2089852)[0m "transformers_version": "5.5.4",
+[36m(WorkerDict pid=2089852)[0m "video_token_id": 248057,
+[36m(WorkerDict pid=2089852)[0m "vision_config": {
+[36m(WorkerDict pid=2089852)[0m "deepstack_visual_indexes": [],[36m(WorkerDict pid=2090328)[0m `torch_dtype` is deprecated! Use `dtype` instead!
+[36m(WorkerDict pid=2090327)[0m [W825 18:08:27.154508901 ProcessGroupNCCL.cpp:929] Warning: TORCH_NCCL_AVOID_RECORD_STREAMS is the default now, this environment variable is thus deprecated. (function operator())[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2090328)[0m The fast path is not available because one of the required library is not installed. Falling back to torch implementation. To install follow https://github.com/fla-org/flash-linear-attention#installation and https://github.com/Dao-AILab/causal-conv1d
+[36m(WorkerDict pid=2090328)[0m [rank7]:[W825 18:08:32.884323464 ProcessGroupNCCL.cpp:5138] Guessing device ID based on global rank. This can cause a hang if rank to GPU mapping is heterogeneous. You can specify device_id in init_process_group()
+[36m(WorkerDict pid=2089852)[0m
Loading weights: 0%| | 0/760 [00:00, ?it/s]
Loading weights: 100%|██████████| 760/760 [00:00<00:00, 10572.92it/s]
+[36m(WorkerDict pid=2089852)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/distributed/c10d_logger.py:83: UserWarning: barrier(): using the device under current context. You can specify `device_id` in `init_process_group` to mute this warning.
+[36m(WorkerDict pid=2089852)[0m return func(*args, **kwargs)
+[36m(WorkerDict pid=2090322)[0m `Qwen2VLImageProcessorFast` is deprecated. The `Fast` suffix for image processors has been removed; use `Qwen2VLImageProcessor` instead.
+[36m(WorkerDict pid=2090326)[0m The `use_fast` parameter is deprecated and will be removed in a future version. Use `backend="torchvision"` instead of `use_fast=True`, or `backend="pil"` instead of `use_fast=False`.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2090323)[0m `torch_dtype` is deprecated! Use `dtype` instead![32m [repeated 6x across cluster][0m
+[36m(WorkerDict pid=2090326)[0m The fast path is not available because one of the required library is not installed. Falling back to torch implementation. To install follow https://github.com/fla-org/flash-linear-attention#installation and https://github.com/Dao-AILab/causal-conv1d[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2089852)[0m [rank0]:[W825 18:08:33.067054600 ProcessGroupNCCL.cpp:5138] Guessing device ID based on global rank. This can cause a hang if rank to GPU mapping is heterogeneous. You can specify device_id in init_process_group()[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2090324)[0m :1241: FutureWarning: The cuda.cudart module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.runtime module instead.
+[36m(WorkerDict pid=2090324)[0m :1241: FutureWarning: The cuda.nvrtc module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.nvrtc module instead.
+[36m(WorkerDict pid=2089852)[0m `Qwen2VLImageProcessorFast` is deprecated. The `Fast` suffix for image processors has been removed; use `Qwen2VLImageProcessor` instead.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2089852)[0m 2026-08-25 18:09:26,834 - INFO - autotuner.py:262 - flashinfer.jit: [Autotuner]: Autotuning process starts ...
+[36m(WorkerDict pid=2089852)[0m 2026-08-25 18:09:26,851 - INFO - autotuner.py:268 - flashinfer.jit: [Autotuner]: Autotuning process ends
+[36m(WorkerDict pid=2090325)[0m :1241: FutureWarning: The cuda.cudart module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.runtime module instead.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2090325)[0m :1241: FutureWarning: The cuda.nvrtc module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.nvrtc module instead.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 0%| | 0/51 [00:00, ?it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 4%|▍ | 2/51 [00:00<00:03, 12.77it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 8%|▊ | 4/51 [00:00<00:03, 12.95it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 12%|█▏ | 6/51 [00:00<00:03, 13.19it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 16%|█▌ | 8/51 [00:00<00:03, 13.43it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 20%|█▉ | 10/51 [00:00<00:02, 13.97it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 24%|██▎ | 12/51 [00:00<00:02, 14.03it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 27%|██▋ | 14/51 [00:01<00:02, 14.62it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 31%|███▏ | 16/51 [00:01<00:02, 15.10it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 37%|███▋ | 19/51 [00:01<00:01, 16.91it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 41%|████ | 21/51 [00:01<00:01, 17.49it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 45%|████▌ | 23/51 [00:01<00:01, 17.56it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 49%|████▉ | 25/51 [00:01<00:01, 17.89it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 55%|█████▍ | 28/51 [00:01<00:01, 18.95it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 61%|██████ | 31/51 [00:01<00:01, 19.67it/s]
+[36m(WorkerDict pid=2090326)[0m 2026-08-25 18:09:26,835 - INFO - autotuner.py:262 - flashinfer.jit: [Autotuner]: Autotuning process starts ...[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2090326)[0m 2026-08-25 18:09:26,849 - INFO - autotuner.py:268 - flashinfer.jit: [Autotuner]: Autotuning process ends[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 65%|██████▍ | 33/51 [00:01<00:00, 19.28it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 71%|███████ | 36/51 [00:02<00:00, 20.71it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 76%|███████▋ | 39/51 [00:02<00:00, 21.55it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 82%|████████▏ | 42/51 [00:02<00:00, 22.59it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 88%|████████▊ | 45/51 [00:02<00:00, 22.90it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 94%|█████████▍| 48/51 [00:02<00:00, 23.73it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 100%|██████████| 51/51 [00:02<00:00, 18.92it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 0%| | 0/51 [00:00, ?it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 4%|▍ | 2/51 [00:00<00:04, 10.91it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 8%|▊ | 4/51 [00:00<00:04, 11.31it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 12%|█▏ | 6/51 [00:00<00:03, 11.61it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 16%|█▌ | 8/51 [00:00<00:03, 12.02it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 20%|█▉ | 10/51 [00:00<00:03, 12.59it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 24%|██▎ | 12/51 [00:00<00:02, 13.20it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 27%|██▋ | 14/51 [00:01<00:02, 13.90it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 31%|███▏ | 16/51 [00:01<00:02, 14.48it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 35%|███▌ | 18/51 [00:01<00:02, 15.16it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 39%|███▉ | 20/51 [00:01<00:02, 15.43it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 43%|████▎ | 22/51 [00:01<00:01, 15.36it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 47%|████▋ | 24/51 [00:01<00:01, 15.86it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 53%|█████▎ | 27/51 [00:01<00:01, 17.42it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 59%|█████▉ | 30/51 [00:01<00:01, 18.81it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 65%|██████▍ | 33/51 [00:02<00:00, 20.36it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 71%|███████ | 36/51 [00:02<00:00, 21.63it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 76%|███████▋ | 39/51 [00:02<00:00, 23.55it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 82%|████████▏ | 42/51 [00:02<00:00, 24.61it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 88%|████████▊ | 45/51 [00:02<00:00, 21.28it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 96%|█████████▌| 49/51 [00:02<00:00, 23.73it/s]
+[36m(WorkerDict pid=2089852)[0m
Capturing CUDA graphs (decode, FULL): 100%|██████████| 51/51 [00:02<00:00, 18.02it/s]
+
+[36m(WorkerDict pid=2089852)[0m "depth": 27,
+[36m(WorkerDict pid=2089852)[0m "dtype": "bfloat16",
+[36m(WorkerDict pid=2089852)[0m "hidden_act": "gelu_pytorch_tanh",
+[36m(WorkerDict pid=2089852)[0m "hidden_size": 1152,
+[36m(WorkerDict pid=2089852)[0m "in_channels": 3,
+[36m(WorkerDict pid=2089852)[0m "initializer_range": 0.02,
+[36m(WorkerDict pid=2089852)[0m "intermediate_size": 4304,
+[36m(WorkerDict pid=2089852)[0m "model_type": "qwen3_5",
+[36m(WorkerDict pid=2089852)[0m "num_heads": 16,
+[36m(WorkerDict pid=2089852)[0m "num_position_embeddings": 2304,
+[36m(WorkerDict pid=2089852)[0m "out_hidden_size": 4096,
+[36m(WorkerDict pid=2089852)[0m "patch_size": 16,
+[36m(WorkerDict pid=2089852)[0m "spatial_merge_size": 2,
+[36m(WorkerDict pid=2089852)[0m "temporal_patch_size": 2
+[36m(WorkerDict pid=2089852)[0m },
+[36m(WorkerDict pid=2089852)[0m "vision_end_token_id": 248054,
+[36m(WorkerDict pid=2089852)[0m "vision_start_token_id": 248053
+[36m(WorkerDict pid=2089852)[0m }
+[36m(WorkerDict pid=2089852)[0m
+[36m(WorkerDict pid=2089852)[0m Ulysses patch applied!
+[36m(WorkerDict pid=2089852)[0m Vision backbone is set to not trainable.
+[36m(WorkerDict pid=2089852)[0m Qwen3_5ForConditionalGeneration contains 9.41B parameters.
+[36m(WorkerDict pid=2089852)[0m After huggingface model init: 1.17 GB / 95.00 GB.
+[36m(WorkerDict pid=2089852)[0m FSDP wrap policy: functools.partial(, transformer_layer_cls={, }).
+[36m(WorkerDict pid=2089852)[0m After FSDP module init: 15.42 GB / 95.00 GB.
+[36m(WorkerDict pid=2089852)[0m After optimizer init: 15.42 GB / 95.00 GB.
+[36m(WorkerDict pid=2090327)[0m Sampling params: {'max_tokens': 4096, 'detokenize': False, 'logit_bias': {248056: -100, 248057: -100}, 'n': 8, 'temperature': 1.0, 'top_p': 0.85, 'top_k': -1, 'ignore_eos': False}.
+[36m(Runner pid=2088024)[0m Config
+[36m(Runner pid=2088024)[0m algorithm:
+[36m(Runner pid=2088024)[0m adv_estimator: grpo
+[36m(Runner pid=2088024)[0m detached_oracle_advantage: true
+[36m(Runner pid=2088024)[0m detached_oracle_advantage_scale: 2.0
+[36m(Runner pid=2088024)[0m detached_oracle_match_best_max: 1.0
+[36m(Runner pid=2088024)[0m detached_oracle_match_best_min: 0.05
+[36m(Runner pid=2088024)[0m detached_oracle_match_best_ratio: 1.2
+[36m(Runner pid=2088024)[0m detached_oracle_use_directional_gain: false
+[36m(Runner pid=2088024)[0m directional_gain: true
+[36m(Runner pid=2088024)[0m directional_gain_gamma: 0.25
+[36m(Runner pid=2088024)[0m directional_gain_positive_only: true
+[36m(Runner pid=2088024)[0m directional_gain_recenter: true
+[36m(Runner pid=2088024)[0m disable_kl: true
+[36m(Runner pid=2088024)[0m gamma: 1.0
+[36m(Runner pid=2088024)[0m kl_coef: 0.0
+[36m(Runner pid=2088024)[0m kl_horizon: 10000.0
+[36m(Runner pid=2088024)[0m kl_penalty: low_var_kl
+[36m(Runner pid=2088024)[0m kl_target: 0.1
+[36m(Runner pid=2088024)[0m kl_type: fixed
+[36m(Runner pid=2088024)[0m lam: 1.0
+[36m(Runner pid=2088024)[0m name: orarl
+[36m(Runner pid=2088024)[0m oracle_builder: orarl.rewards:build_oracle_response_from_ground_truth
+[36m(Runner pid=2088024)[0m oracle_injection: true
+[36m(Runner pid=2088024)[0m oracle_injection_mode: append
+[36m(Runner pid=2088024)[0m oracle_log_exclude: true
+[36m(Runner pid=2088024)[0m oracle_replace_index: -1
+[36m(Runner pid=2088024)[0m oracle_reward_gate_beta: 2.0
+[36m(Runner pid=2088024)[0m post_selection_recenter: true
+[36m(Runner pid=2088024)[0m post_selection_rms_match: true
+[36m(Runner pid=2088024)[0m post_selection_rms_min_scale: 0.25
+[36m(Runner pid=2088024)[0m scale_rewards: false
+[36m(Runner pid=2088024)[0m selection_keep_oracle: true
+[36m(Runner pid=2088024)[0m selection_negative_quota: 2
+[36m(Runner pid=2088024)[0m selection_positive_quota: 1
+[36m(Runner pid=2088024)[0m selection_prune_ratio: 0.5
+[36m(Runner pid=2088024)[0m selection_strict_sign_balance: true
+[36m(Runner pid=2088024)[0m use_kl_loss: false
+[36m(Runner pid=2088024)[0m data:
+[36m(Runner pid=2088024)[0m answer_key: answer
+[36m(Runner pid=2088024)[0m dataloader_num_workers: 0
+[36m(Runner pid=2088024)[0m enable_thinking: false
+[36m(Runner pid=2088024)[0m filter_overlong_prompts: false
+[36m(Runner pid=2088024)[0m filter_overlong_prompts_workers: 16
+[36m(Runner pid=2088024)[0m format_prompt: null
+[36m(Runner pid=2088024)[0m group_by_task: true
+[36m(Runner pid=2088024)[0m group_by_task_key: problem_type
+[36m(Runner pid=2088024)[0m image_dir: /
+[36m(Runner pid=2088024)[0m image_key: images
+[36m(Runner pid=2088024)[0m image_max_pixels: 1048576
+[36m(Runner pid=2088024)[0m image_min_pixels: 4096
+[36m(Runner pid=2088024)[0m inline_video_tensors: true
+[36m(Runner pid=2088024)[0m max_pixels: 4194304
+[36m(Runner pid=2088024)[0m max_prompt_length: 24576
+[36m(Runner pid=2088024)[0m max_response_length: 4096
+[36m(Runner pid=2088024)[0m min_pixels: 4096
+[36m(Runner pid=2088024)[0m mini_rollout_batch_size: null
+[36m(Runner pid=2088024)[0m override_chat_template: null
+[36m(Runner pid=2088024)[0m preprocessed_video_dir: null
+[36m(Runner pid=2088024)[0m prompt_key: problem
+[36m(Runner pid=2088024)[0m response_prefix: ''
+[36m(Runner pid=2088024)[0m rollout_batch_size: 8
+[36m(Runner pid=2088024)[0m seed: 42
+[36m(Runner pid=2088024)[0m shuffle: true
+[36m(Runner pid=2088024)[0m train_files: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl
+[36m(Runner pid=2088024)[0m use_preprocessed_videos: false
+[36m(Runner pid=2088024)[0m val_batch_size: 16
+[36m(Runner pid=2088024)[0m val_files: /mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl
+[36m(Runner pid=2088024)[0m val_preprocessed_video_dir: null
+[36m(Runner pid=2088024)[0m val_video_fps: 2.0
+[36m(Runner pid=2088024)[0m val_video_max_frames: 128
+[36m(Runner pid=2088024)[0m val_video_max_pixels: 786432
+[36m(Runner pid=2088024)[0m val_video_min_pixels: 4096
+[36m(Runner pid=2088024)[0m val_video_source_mode: realtime_only
+[36m(Runner pid=2088024)[0m val_video_total_pixels: 8388608
+[36m(Runner pid=2088024)[0m video_fps: 2.0
+[36m(Runner pid=2088024)[0m video_key: videos
+[36m(Runner pid=2088024)[0m video_max_frames: 128
+[36m(Runner pid=2088024)[0m video_max_pixels: 786432
+[36m(Runner pid=2088024)[0m video_min_pixels: 4096
+[36m(Runner pid=2088024)[0m video_source_mode: realtime_only
+[36m(Runner pid=2088024)[0m video_total_pixels: 8388608
+[36m(Runner pid=2088024)[0m trainer:
+[36m(Runner pid=2088024)[0m best_train_metric_key: reward/overall
+[36m(Runner pid=2088024)[0m best_train_min_step: 10
+[36m(Runner pid=2088024)[0m best_train_smooth_window: 5
+[36m(Runner pid=2088024)[0m critic_warmup: 0
+[36m(Runner pid=2088024)[0m experiment_name: smoke-orarl-9b
+[36m(Runner pid=2088024)[0m find_last_checkpoint: false
+[36m(Runner pid=2088024)[0m keep_best_train_ckpt: false
+[36m(Runner pid=2088024)[0m keep_optim_only_latest: true
+[36m(Runner pid=2088024)[0m load_checkpoint_path: null
+[36m(Runner pid=2088024)[0m logger:
+[36m(Runner pid=2088024)[0m - console
+[36m(Runner pid=2088024)[0m max_steps: 1
+[36m(Runner pid=2088024)[0m n_gpus_per_node: 8
+[36m(Runner pid=2088024)[0m nnodes: 1
+[36m(Runner pid=2088024)[0m project_name: OraRL
+[36m(Runner pid=2088024)[0m ray_timeline: null
+[36m(Runner pid=2088024)[0m save_checkpoint_path: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl
+[36m(Runner pid=2088024)[0m save_freq: 1
+[36m(Runner pid=2088024)[0m save_limit: 3
+[36m(Runner pid=2088024)[0m save_model_only: false
+[36m(Runner pid=2088024)[0m total_epochs: 1
+[36m(Runner pid=2088024)[0m val_before_train: false
+[36m(Runner pid=2088024)[0m val_freq: -1
+[36m(Runner pid=2088024)[0m val_generations_to_log: 0
+[36m(Runner pid=2088024)[0m val_only: false
+[36m(Runner pid=2088024)[0m worker:
+[36m(Runner pid=2088024)[0m actor:
+[36m(Runner pid=2088024)[0m clip_ratio_dual: 3.0
+[36m(Runner pid=2088024)[0m clip_ratio_high: 0.2
+[36m(Runner pid=2088024)[0m clip_ratio_low: 0.2
+[36m(Runner pid=2088024)[0m disable_kl: true
+[36m(Runner pid=2088024)[0m dynamic_batching: true
+[36m(Runner pid=2088024)[0m fsdp:
+[36m(Runner pid=2088024)[0m enable_cpu_offload: false
+[36m(Runner pid=2088024)[0m enable_full_shard: true
+[36m(Runner pid=2088024)[0m enable_rank0_init: true
+[36m(Runner pid=2088024)[0m fsdp_size: -1
+[36m(Runner pid=2088024)[0m mp_buffer_dtype: fp32
+[36m(Runner pid=2088024)[0m mp_param_dtype: bf16
+[36m(Runner pid=2088024)[0m mp_reduce_dtype: fp32
+[36m(Runner pid=2088024)[0m torch_dtype: bf16
+[36m(Runner pid=2088024)[0m use_orig_params: false
+[36m(Runner pid=2088024)[0m global_batch_size: 8
+[36m(Runner pid=2088024)[0m global_batch_size_per_device: -1
+[36m(Runner pid=2088024)[0m kl_coef: 0.0
+[36m(Runner pid=2088024)[0m kl_penalty: low_var_kl
+[36m(Runner pid=2088024)[0m loss_avg_mode: token
+[36m(Runner pid=2088024)[0m loss_type: default
+[36m(Runner pid=2088024)[0m max_grad_norm: 1.0
+[36m(Runner pid=2088024)[0m max_token_len_per_gpu: 32768
+[36m(Runner pid=2088024)[0m micro_batch_size_per_device_for_experience: 1
+[36m(Runner pid=2088024)[0m micro_batch_size_per_device_for_update: 1
+[36m(Runner pid=2088024)[0m model:
+[36m(Runner pid=2088024)[0m enable_gradient_checkpointing: true
+[36m(Runner pid=2088024)[0m freeze_vision_tower: true
+[36m(Runner pid=2088024)[0m model_path: /apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B
+[36m(Runner pid=2088024)[0m override_config: {}
+[36m(Runner pid=2088024)[0m tokenizer_path: /apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B
+[36m(Runner pid=2088024)[0m train_vision_merger: false
+[36m(Runner pid=2088024)[0m trust_remote_code: false
+[36m(Runner pid=2088024)[0m offload:
+[36m(Runner pid=2088024)[0m offload_optimizer: false
+[36m(Runner pid=2088024)[0m offload_params: false
+[36m(Runner pid=2088024)[0m optim:
+[36m(Runner pid=2088024)[0m betas:
+[36m(Runner pid=2088024)[0m - 0.9
+[36m(Runner pid=2088024)[0m - 0.999
+[36m(Runner pid=2088024)[0m lr: 1.0e-06
+[36m(Runner pid=2088024)[0m lr_scheduler_type: constant
+[36m(Runner pid=2088024)[0m lr_warmup_ratio: 0.0
+[36m(Runner pid=2088024)[0m lr_warmup_steps: null
+[36m(Runner pid=2088024)[0m min_lr_ratio: null
+[36m(Runner pid=2088024)[0m strategy: adamw
+[36m(Runner pid=2088024)[0m training_steps: 1
+[36m(Runner pid=2088024)[0m weight_decay: 0.0
+[36m(Runner pid=2088024)[0m padding_free: true
+[36m(Runner pid=2088024)[0m ppo_epochs: 1
+[36m(Runner pid=2088024)[0m selection_prune_ratio: 0.5
+[36m(Runner pid=2088024)[0m strategy: fsdp
+[36m(Runner pid=2088024)[0m ulysses_size: 1
+[36m(Runner pid=2088024)[0m use_kl_loss: false
+[36m(Runner pid=2088024)[0m use_torch_compile: false
+[36m(Runner pid=2088024)[0m critic:
+[36m(Runner pid=2088024)[0m cliprange_value: 0.5
+[36m(Runner pid=2088024)[0m dynamic_batching: true
+[36m(Runner pid=2088024)[0m fsdp:
+[36m(Runner pid=2088024)[0m enable_cpu_offload: false
+[36m(Runner pid=2088024)[0m enable_full_shard: true
+[36m(Runner pid=2088024)[0m enable_rank0_init: true
+[36m(Runner pid=2088024)[0m fsdp_size: -1
+[36m(Runner pid=2088024)[0m mp_buffer_dtype: fp32
+[36m(Runner pid=2088024)[0m mp_param_dtype: bf16
+[36m(Runner pid=2088024)[0m mp_reduce_dtype: fp32
+[36m(Runner pid=2088024)[0m torch_dtype: null
+[36m(Runner pid=2088024)[0m use_orig_params: false
+[36m(Runner pid=2088024)[0m global_batch_size: 256
+[36m(Runner pid=2088024)[0m global_batch_size_per_device: -1
+[36m(Runner pid=2088024)[0m loss_avg_mode: token
+[36m(Runner pid=2088024)[0m max_grad_norm: 1.0
+[36m(Runner pid=2088024)[0m max_token_len_per_gpu: null
+[36m(Runner pid=2088024)[0m micro_batch_size_per_device_for_experience: 16
+[36m(Runner pid=2088024)[0m micro_batch_size_per_device_for_update: 4
+[36m(Runner pid=2088024)[0m model:
+[36m(Runner pid=2088024)[0m enable_gradient_checkpointing: true
+[36m(Runner pid=2088024)[0m freeze_vision_tower: false
+[36m(Runner pid=2088024)[0m model_path: null
+[36m(Runner pid=2088024)[0m override_config: {}
+[36m(Runner pid=2088024)[0m tokenizer_path: null
+[36m(Runner pid=2088024)[0m train_vision_merger: false
+[36m(Runner pid=2088024)[0m trust_remote_code: true
+[36m(Runner pid=2088024)[0m offload:
+[36m(Runner pid=2088024)[0m offload_optimizer: false
+[36m(Runner pid=2088024)[0m offload_params: false
+[36m(Runner pid=2088024)[0m optim:
+[36m(Runner pid=2088024)[0m betas:
+[36m(Runner pid=2088024)[0m - 0.9
+[36m(Runner pid=2088024)[0m - 0.999
+[36m(Runner pid=2088024)[0m lr: 1.0e-06
+[36m(Runner pid=2088024)[0m lr_scheduler_type: constant
+[36m(Runner pid=2088024)[0m lr_warmup_ratio: 0.0
+[36m(Runner pid=2088024)[0m lr_warmup_steps: null
+[36m(Runner pid=2088024)[0m min_lr_ratio: null
+[36m(Runner pid=2088024)[0m strategy: adamw
+[36m(Runner pid=2088024)[0m training_steps: 1
+[36m(Runner pid=2088024)[0m weight_decay: 0.01
+[36m(Runner pid=2088024)[0m padding_free: false
+[36m(Runner pid=2088024)[0m ppo_epochs: 1
+[36m(Runner pid=2088024)[0m strategy: fsdp
+[36m(Runner pid=2088024)[0m ulysses_size: 1
+[36m(Runner pid=2088024)[0m hybrid_engine: true
+[36m(Runner pid=2088024)[0m ref:
+[36m(Runner pid=2088024)[0m dynamic_batching: true
+[36m(Runner pid=2088024)[0m fsdp:
+[36m(Runner pid=2088024)[0m enable_cpu_offload: false
+[36m(Runner pid=2088024)[0m enable_full_shard: true
+[36m(Runner pid=2088024)[0m enable_rank0_init: true
+[36m(Runner pid=2088024)[0m fsdp_size: -1
+[36m(Runner pid=2088024)[0m mp_buffer_dtype: fp32
+[36m(Runner pid=2088024)[0m mp_param_dtype: bf16
+[36m(Runner pid=2088024)[0m mp_reduce_dtype: fp32
+[36m(Runner pid=2088024)[0m torch_dtype: bf16
+[36m(Runner pid=2088024)[0m use_orig_params: false
+[36m(Runner pid=2088024)[0m max_token_len_per_gpu: 32768
+[36m(Runner pid=2088024)[0m micro_batch_size_per_device_for_experience: 1
+[36m(Runner pid=2088024)[0m offload:
+[36m(Runner pid=2088024)[0m offload_optimizer: false
+[36m(Runner pid=2088024)[0m offload_params: false
+[36m(Runner pid=2088024)[0m padding_free: true
+[36m(Runner pid=2088024)[0m strategy: fsdp
+[36m(Runner pid=2088024)[0m ulysses_size: 1
+[36m(Runner pid=2088024)[0m use_torch_compile: false
+[36m(Runner pid=2088024)[0m reward:
+[36m(Runner pid=2088024)[0m num_cpus: 1
+[36m(Runner pid=2088024)[0m reward_function: orarl.rewards
+[36m(Runner pid=2088024)[0m reward_function_is_module: true
+[36m(Runner pid=2088024)[0m reward_function_kwargs: {}
+[36m(Runner pid=2088024)[0m reward_function_name: compute_score
+[36m(Runner pid=2088024)[0m skip_special_tokens: true
+[36m(Runner pid=2088024)[0m rollout:
+[36m(Runner pid=2088024)[0m calculate_log_probs: false
+[36m(Runner pid=2088024)[0m collect_seq_logprob_for_filter: false
+[36m(Runner pid=2088024)[0m disable_log_stats: true
+[36m(Runner pid=2088024)[0m disable_tqdm: true
+[36m(Runner pid=2088024)[0m dtype: bf16
+[36m(Runner pid=2088024)[0m enable_chunked_prefill: true
+[36m(Runner pid=2088024)[0m enforce_eager: false
+[36m(Runner pid=2088024)[0m gpu_memory_utilization: 0.45
+[36m(Runner pid=2088024)[0m ignore_eos: false
+[36m(Runner pid=2088024)[0m kv_cache_dtype: auto
+[36m(Runner pid=2088024)[0m limit_images: 10
+[36m(Runner pid=2088024)[0m max_model_len: null
+[36m(Runner pid=2088024)[0m max_num_batched_tokens: 32768
+[36m(Runner pid=2088024)[0m n: 8
+[36m(Runner pid=2088024)[0m name: vllm
+[36m(Runner pid=2088024)[0m prompt_length: 24576
+[36m(Runner pid=2088024)[0m response_length: 4096
+[36m(Runner pid=2088024)[0m seed: 42
+[36m(Runner pid=2088024)[0m temperature: 1.0
+[36m(Runner pid=2088024)[0m tensor_parallel_size: 1
+[36m(Runner pid=2088024)[0m top_k: -1
+[36m(Runner pid=2088024)[0m top_p: 0.85
+[36m(Runner pid=2088024)[0m trust_remote_code: false
+[36m(Runner pid=2088024)[0m val_override_config:
+[36m(Runner pid=2088024)[0m n: 1
+[36m(Runner pid=2088024)[0m temperature: 0.7
+[36m(Runner pid=2088024)[0m top_p: 0.95
+[36m(Runner pid=2088024)[0m
+
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:00, ?it/s]
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:00, ?it/s]
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:00, ?it/s]
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:01, ?it/s]
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:02, ?it/s]
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:02, ?it/s]
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:02, ?it/s]
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:21, ?it/s]
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:21, ?it/s]
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [00:22, ?it/s][36m(WorkerDict pid=2089852)[0m After vllm init: 7.01 GB / 95.00 GB.
+[36m(WorkerDict pid=2089852)[0m Before vllm wake up in sharding manager: 7.01 GB / 95.00 GB.
+[36m(WorkerDict pid=2089852)[0m After gather model weights in sharding manager: 30.92 GB / 95.00 GB.
+[36m(WorkerDict pid=2089852)[0m After sync model weights in sharding manager: 24.69 GB / 95.00 GB.
+[36m(WorkerDict pid=2089852)[0m After vllm wake up in sharding manager: 45.04 GB / 95.00 GB.
+[36m(Runner pid=2088024)[0m Start generating batch...
+[36m(WorkerDict pid=2089852)[0m Before vllm offload in sharding manager: 50.43 GB / 95.00 GB.
+[36m(WorkerDict pid=2090325)[0m Sampling params: {'max_tokens': 4096, 'detokenize': False, 'logit_bias': {248056: -100, 248057: -100}, 'n': 8, 'temperature': 1.0, 'top_p': 0.85, 'top_k': -1, 'ignore_eos': False}.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2089852)[0m After vllm offload in sharding manager: 7.40 GB / 95.00 GB.
+
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 0%| | 0.00/2.00 [00:00, ?it/s][A[A
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [00:08<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [00:08<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 0%| | 0.00/1.00 [00:00, ?it/s][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 0%| | 0.00/1.00 [00:00, ?it/s][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A[36m(WorkerDict pid=2089852)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/distributed/c10d_logger.py:83: UserWarning: barrier(): using the device under current context. You can specify `device_id` in `init_process_group` to mute this warning.
+
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [01:10, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [00:43<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [00:26<00:00, 26.3s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [00:25<00:00, 25.7s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A[36m(WorkerDict pid=2089852)[0m return func(*args, **kwargs)
+
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [01:10, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [00:43<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [00:26<00:00, 26.3s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [00:25<00:00, 25.7s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [01:17, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [00:50<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [00:33<00:00, 33.2s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [00:32<00:00, 32.6s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [01:17, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [00:50<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [00:33<00:00, 33.2s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [00:32<00:00, 32.6s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [01:17, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [00:50<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [00:33<00:00, 33.2s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [00:32<00:00, 32.6s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A[36m(WorkerDict pid=2089852)[0m /data/miniconda3/envs/orarl/lib/python3.11/site-packages/torch/distributed/c10d_logger.py:83: UserWarning: barrier(): using the device under current context. You can specify `device_id` in `init_process_group` to mute this warning.
+
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:57, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:30<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:13<00:00, 133s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:12<00:00, 133s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A[36m(WorkerDict pid=2089852)[0m return func(*args, **kwargs)
+
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:57, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:30<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:13<00:00, 133s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:12<00:00, 133s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A[36m(WorkerDict pid=2089852)[0m [rank-0]: Saving model to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_0.pt.
+[36m(WorkerDict pid=2089852)[0m [rank-0]: Saving optimizer to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_0.pt.
+[36m(WorkerDict pid=2089852)[0m [rank-0]: Saving extra_state to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_0.pt.
+[36m(Runner pid=2088024)[0m [TRAIN STEP] 1/1 step_s=179.2 elapsed=2m59s eta=0m00s
+[36m(Runner pid=2088024)[0m Step 1
+[36m(Runner pid=2088024)[0m actor:
+[36m(Runner pid=2088024)[0m backward_rows: 32.0
+[36m(Runner pid=2088024)[0m backward_rows_per_prompt: 4.0
+[36m(Runner pid=2088024)[0m entropy_loss: 0.355
+[36m(Runner pid=2088024)[0m grad_norm: 5.594
+[36m(Runner pid=2088024)[0m lr: 1.0e-06
+[36m(Runner pid=2088024)[0m oracle_injection_enabled: 1.0
+[36m(Runner pid=2088024)[0m orarl_selection_enabled: 1.0
+[36m(Runner pid=2088024)[0m pg_clipfrac_higher: 0.027
+[36m(Runner pid=2088024)[0m pg_clipfrac_lower: 0.0
+[36m(Runner pid=2088024)[0m pg_loss: 0.002
+[36m(Runner pid=2088024)[0m ppo_kl: 0.005
+[36m(Runner pid=2088024)[0m critic:
+[36m(Runner pid=2088024)[0m advantages:
+[36m(Runner pid=2088024)[0m max: 0.541
+[36m(Runner pid=2088024)[0m mean: 1.0643687042843908e-09
+[36m(Runner pid=2088024)[0m min: -0.484
+[36m(Runner pid=2088024)[0m std: 0.366
+[36m(Runner pid=2088024)[0m returns:
+[36m(Runner pid=2088024)[0m max: 0.913
+[36m(Runner pid=2088024)[0m mean: -0.015
+[36m(Runner pid=2088024)[0m min: -0.75
+[36m(Runner pid=2088024)[0m rewards:
+[36m(Runner pid=2088024)[0m max: 1.0
+[36m(Runner pid=2088024)[0m mean: 0.594
+[36m(Runner pid=2088024)[0m min: 0.0
+[36m(Runner pid=2088024)[0m std: 0.491
+[36m(Runner pid=2088024)[0m score:
+[36m(Runner pid=2088024)[0m max: 1.0
+[36m(Runner pid=2088024)[0m mean: 0.594
+[36m(Runner pid=2088024)[0m min: 0.0
+[36m(Runner pid=2088024)[0m std: 0.491
+[36m(Runner pid=2088024)[0m var: 0.241
+[36m(Runner pid=2088024)[0m global_seqlen:
+[36m(Runner pid=2088024)[0m balanced_max: 63162
+[36m(Runner pid=2088024)[0m balanced_min: 60075
+[36m(Runner pid=2088024)[0m max: 76879
+[36m(Runner pid=2088024)[0m mean: 61474.5
+[36m(Runner pid=2088024)[0m min: 42686
+[36m(Runner pid=2088024)[0m minmax_diff: 34193
+[36m(Runner pid=2088024)[0m orarl:
+[36m(Runner pid=2088024)[0m degenerate_op_groups: 3.0
+[36m(Runner pid=2088024)[0m detached_a_oracle_mean: 0.3
+[36m(Runner pid=2088024)[0m detached_a_oracle_raw_mean: 0.574
+[36m(Runner pid=2088024)[0m detached_best_op_adv_mean: 0.298
+[36m(Runner pid=2088024)[0m detached_gain_mean: 1.0
+[36m(Runner pid=2088024)[0m detached_groups: 8.0
+[36m(Runner pid=2088024)[0m detached_match_cap_applied_frac: 0.375
+[36m(Runner pid=2088024)[0m detached_match_cap_mean: 0.377
+[36m(Runner pid=2088024)[0m detached_w_mean: 0.287
+[36m(Runner pid=2088024)[0m detached_w_mean_by_task:
+[36m(Runner pid=2088024)[0m image_sequence_mc_answer_only: 0.287
+[36m(Runner pid=2088024)[0m directional_gain_amplified_frac: 0.328
+[36m(Runner pid=2088024)[0m directional_gain_max: 4.0
+[36m(Runner pid=2088024)[0m directional_gain_mean: 1.378
+[36m(Runner pid=2088024)[0m directional_gain_positive_only: 1.0
+[36m(Runner pid=2088024)[0m directional_gain_recenter: 1.0
+[36m(Runner pid=2088024)[0m directional_gain_recenter_abs_shift_max: 0.004
+[36m(Runner pid=2088024)[0m directional_gain_recenter_abs_shift_mean: 0.001
+[36m(Runner pid=2088024)[0m groups_with_no_op_pos_baseline: 3.0
+[36m(Runner pid=2088024)[0m mu_g_mean: 0.625
+[36m(Runner pid=2088024)[0m mu_op_mean: 0.578
+[36m(Runner pid=2088024)[0m n_groups: 8.0
+[36m(Runner pid=2088024)[0m op_adv_neg_count: 19.0
+[36m(Runner pid=2088024)[0m op_adv_neg_mean: 0.455
+[36m(Runner pid=2088024)[0m op_adv_pos_count: 21.0
+[36m(Runner pid=2088024)[0m op_adv_pos_mean: 0.412
+[36m(Runner pid=2088024)[0m oracle:
+[36m(Runner pid=2088024)[0m groups_injected: 8
+[36m(Runner pid=2088024)[0m oracle_adv_mag_max: 1.0
+[36m(Runner pid=2088024)[0m oracle_adv_mag_mean: 0.422
+[36m(Runner pid=2088024)[0m oracle_mean_contamination_of_means: 0.162
+[36m(Runner pid=2088024)[0m oracle_mean_contamination_per_group: 0.114
+[36m(Runner pid=2088024)[0m post_selection_active_mean_after: 9.313225746154785e-10
+[36m(Runner pid=2088024)[0m post_selection_active_mean_before: -0.015
+[36m(Runner pid=2088024)[0m post_selection_active_policy_rows: 3.0
+[36m(Runner pid=2088024)[0m post_selection_active_rms_after: 0.292
+[36m(Runner pid=2088024)[0m post_selection_active_rms_before: 0.344
+[36m(Runner pid=2088024)[0m post_selection_active_rows: 4.0
+[36m(Runner pid=2088024)[0m post_selection_enabled: 1.0
+[36m(Runner pid=2088024)[0m post_selection_groups: 8.0
+[36m(Runner pid=2088024)[0m post_selection_groups_skipped: 0.0
+[36m(Runner pid=2088024)[0m post_selection_on_policy_rms: 0.291
+[36m(Runner pid=2088024)[0m post_selection_oracle_advantage_after: 0.289
+[36m(Runner pid=2088024)[0m post_selection_oracle_rows: 1.0
+[36m(Runner pid=2088024)[0m post_selection_oracle_sign_projection: 0.0
+[36m(Runner pid=2088024)[0m post_selection_recenter: 1.0
+[36m(Runner pid=2088024)[0m post_selection_rms_match: 1.0
+[36m(Runner pid=2088024)[0m post_selection_rms_scale: 0.954
+[36m(Runner pid=2088024)[0m post_selection_sigma_op_fallback: 0.375
+[36m(Runner pid=2088024)[0m scale_rewards: 0.0
+[36m(Runner pid=2088024)[0m selection:
+[36m(Runner pid=2088024)[0m abs_advantage_dropped_mean: 0.241
+[36m(Runner pid=2088024)[0m abs_advantage_kept_mean: 0.314
+[36m(Runner pid=2088024)[0m cross_sign_fallback_rows: 0.0
+[36m(Runner pid=2088024)[0m dropped_rows: 40.0
+[36m(Runner pid=2088024)[0m effective_keep_ratio: 0.444
+[36m(Runner pid=2088024)[0m full_batch_score_max: 1.0
+[36m(Runner pid=2088024)[0m full_batch_score_mean: 0.625
+[36m(Runner pid=2088024)[0m full_batch_score_min: 0.0
+[36m(Runner pid=2088024)[0m groups: 8.0
+[36m(Runner pid=2088024)[0m keep_per_group: 4.0
+[36m(Runner pid=2088024)[0m kept_rows: 32.0
+[36m(Runner pid=2088024)[0m negative_policy_rows_kept: 10.0
+[36m(Runner pid=2088024)[0m oracle_rows_forced: 8.0
+[36m(Runner pid=2088024)[0m positive_policy_rows_kept: 5.0
+[36m(Runner pid=2088024)[0m zero_fallback_rows: 9.0
+[36m(Runner pid=2088024)[0m zero_policy_rows_kept: 9.0
+[36m(Runner pid=2088024)[0m sigma_g_mean: 0.33
+[36m(Runner pid=2088024)[0m sigma_op_fallback_count: 3.0
+[36m(Runner pid=2088024)[0m sigma_op_mean: 0.29
+[36m(Runner pid=2088024)[0m sigma_ratio_of_means: 1.139
+[36m(Runner pid=2088024)[0m sigma_ratio_per_group: 1.005
+[36m(Runner pid=2088024)[0m sign_flip_count: 0.0
+[36m(Runner pid=2088024)[0m sign_flip_rate: 0.0
+[36m(Runner pid=2088024)[0m orarl_selection_seqlen:
+[36m(Runner pid=2088024)[0m balanced_max: 27878
+[36m(Runner pid=2088024)[0m balanced_min: 26766
+[36m(Runner pid=2088024)[0m max: 31041
+[36m(Runner pid=2088024)[0m mean: 27322.0
+[36m(Runner pid=2088024)[0m min: 23594
+[36m(Runner pid=2088024)[0m minmax_diff: 7447
+[36m(Runner pid=2088024)[0m perf:
+[36m(Runner pid=2088024)[0m cpu_memory_used_gb: 1082.023
+[36m(Runner pid=2088024)[0m max_memory_allocated_gb: 68.308
+[36m(Runner pid=2088024)[0m max_memory_reserved_gb: 84.883
+[36m(Runner pid=2088024)[0m mfu_actor: 0.444
+[36m(Runner pid=2088024)[0m throughput: 152.448
+[36m(Runner pid=2088024)[0m time_per_step: 179.222
+[36m(Runner pid=2088024)[0m total_num_tokens: 218576
+[36m(Runner pid=2088024)[0m prompt_length:
+[36m(Runner pid=2088024)[0m clip_ratio: 0.0
+[36m(Runner pid=2088024)[0m max: 8554.0
+[36m(Runner pid=2088024)[0m mean: 6823.5
+[36m(Runner pid=2088024)[0m min: 2280.0
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:14<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 0%| | 0.00/1.00 [02:59, ?it/s]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
[2m[36m(pid=2088024) [0mRunning step: 100%|██████████| 1.00/1.00 [02:59<00:00, 179s/it]
[2m[36m(pid=2088024) [0mRunning step: 100%|██████████| 1.00/1.00 [02:59<00:00, 179s/it]
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 100%|██████████| 1.00/1.00 [02:59<00:00, 179s/it]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 100%|██████████| 1.00/1.00 [02:59<00:00, 179s/it]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+
+
[A[A
+
+
[A[A
+
+
+
[A[A[A
[2m[36m(pid=2088024) [0mRunning step: 100%|██████████| 1.00/1.00 [02:59<00:00, 179s/it]
+
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:32<00:00, 4.02s/it][A[A
+
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:15<00:00, 135s/it][A[A
+
+
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:14<00:00, 134s/it][A[A[A
+[36m(Runner pid=2088024)[0m response_length:
+[36m(Runner pid=2088024)[0m clip_ratio: 0.0
+[36m(Runner pid=2088024)[0m max: 7.0
+[36m(Runner pid=2088024)[0m mean: 7.0
+[36m(Runner pid=2088024)[0m min: 7.0
+[36m(Runner pid=2088024)[0m reward:
+[36m(Runner pid=2088024)[0m accuracy: 0.625
+[36m(Runner pid=2088024)[0m format: 1.0
+[36m(Runner pid=2088024)[0m overall: 0.625
+[36m(Runner pid=2088024)[0m rollout:
+[36m(Runner pid=2088024)[0m all_identical_group_fraction: 0.375
+[36m(Runner pid=2088024)[0m exact_unique_fraction: 0.281
+[36m(Runner pid=2088024)[0m pairwise_token_agreement_mean: 0.943
+[36m(Runner pid=2088024)[0m response_length_std_mean: 0.0
+[36m(Runner pid=2088024)[0m reward_group_std_mean: 0.29
+[36m(Runner pid=2088024)[0m reward_unique_fraction: 0.203
+[36m(Runner pid=2088024)[0m zero_reward_std_group_fraction: 0.375
+[36m(Runner pid=2088024)[0m timing_per_token_ms:
+[36m(Runner pid=2088024)[0m adv: 4.752148470787695e-05
+[36m(Runner pid=2088024)[0m gen: 100.661
+[36m(Runner pid=2088024)[0m old: 0.089
+[36m(Runner pid=2088024)[0m reward: 0.016
+[36m(Runner pid=2088024)[0m update_actor: 0.128
+[36m(Runner pid=2088024)[0m timing_s:
+[36m(Runner pid=2088024)[0m adv: 0.01
+[36m(Runner pid=2088024)[0m gen: 22.548
+[36m(Runner pid=2088024)[0m old: 19.508
+[36m(Runner pid=2088024)[0m orarl_selection: 0.013
+[36m(Runner pid=2088024)[0m post_selection_balance: 0.002
+[36m(Runner pid=2088024)[0m reward: 0.004
+[36m(Runner pid=2088024)[0m save_checkpoint: 109.13
+[36m(Runner pid=2088024)[0m step: 179.222
+[36m(Runner pid=2088024)[0m update_actor: 27.961
+[36m(Runner pid=2088024)[0m
+[36m(WorkerDict pid=2090326)[0m [rank-5]: Saving model to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/model_world_size_8_rank_5.pt.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2090326)[0m [rank-5]: Saving optimizer to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/optim_world_size_8_rank_5.pt.[32m [repeated 7x across cluster][0m
+[36m(WorkerDict pid=2090326)[0m [rank-5]: Saving extra_state to /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl/global_step_1/actor/extra_state_world_size_8_rank_5.pt.[32m [repeated 7x across cluster][0m
+
[2m[36m(pid=2088024) [0mRunning step: 100%|██████████| 1.00/1.00 [03:03<00:00, 184s/it]
+
[2m[36m(pid=2089852) [0mCompute log probs: 100%|██████████| 2.00/2.00 [02:36<00:00, 78.4s/it]
+
[2m[36m(pid=2089852) [0mTrain mini-batches: 100%|██████████| 1.00/1.00 [02:19<00:00, 139s/it]
+
[2m[36m(pid=2089852) [0mUpdate policy: 100%|██████████| 1.00/1.00 [02:18<00:00, 139s/it]
+method=orarl mode=run
+/data/miniconda3/envs/orarl/bin/python -m verl.trainer.main config=/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/configs/orarl_9b.yaml trainer.max_steps=1 trainer.save_freq=1 trainer.val_before_train=false trainer.experiment_name=smoke-orarl-9b data.rollout_batch_size=8 worker.actor.global_batch_size=8 worker.actor.model.model_path=/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B worker.actor.model.tokenizer_path=/apdcephfs_gy7/share_305340263/hunyuan/yunhengli/checkpoint/Video-ORA-9B data.train_files=/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_joint_100k_maskaware.jsonl data.val_files=/mnt/lyh/TempSamp/EasyVideoR1/data/joint_final/rl_phase3_49152.canary.jsonl trainer.save_checkpoint_path=/mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl trainer.nnodes=1 trainer.n_gpus_per_node=8
+checkpoint OK: orarl -> /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl/global_step_1
+
+Smoke training finished.
+Log: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/smoke.log
+GRPO output: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/grpo
+OraRL output: /mnt/lyh/TempSamp/EasyVideoR1/OraRL-official/runs/smoke-training-9b-20260825_175949/orarl
diff --git a/code/scripts/check_environment.py b/code/scripts/check_environment.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc191f1e32877dae4d4cf33854940d8adc843e94
--- /dev/null
+++ b/code/scripts/check_environment.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+"""Validate the pinned OraRL runtime and an optional local checkpoint."""
+
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import json
+import shutil
+import subprocess
+import sys
+from importlib import metadata
+from pathlib import Path
+from typing import Sequence
+
+EXPECTED_DISTRIBUTIONS = {
+ "flash-attn": "2.8.3",
+ "flash-linear-attention": "0.4.2",
+ "orarl": "0.1.0",
+ "qwen-vl-utils": "0.0.14",
+ "ray": "2.54.0",
+ "torchcodec": "0.10.0",
+ "transformers": "5.5.4",
+ "vllm": "0.19.1",
+}
+TRAINING_MODULES = (
+ "verl.trainer.main",
+ "verl.trainer.ray_trainer",
+ "verl.workers.fsdp_workers",
+)
+REQUIRED_MODEL_FILES = {
+ "config.json",
+ "generation_config.json",
+ "model.safetensors.index.json",
+ "processor_config.json",
+ "tokenizer.json",
+ "tokenizer_config.json",
+}
+
+
+def _arguments(argv: Sequence[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--require-gpu",
+ action="store_true",
+ help="fail when CUDA is unavailable (use this inside a GPU allocation)",
+ )
+ parser.add_argument(
+ "--require-h20",
+ action="store_true",
+ help="fail unless at least one visible GPU is an NVIDIA H20",
+ )
+ parser.add_argument(
+ "--model",
+ type=Path,
+ help="optionally validate checkpoint metadata and every indexed weight shard",
+ )
+ parser.add_argument(
+ "--evaluation-only",
+ action="store_true",
+ help="skip the checks that only training runs need",
+ )
+ return parser.parse_args(argv)
+
+
+def _check_versions(errors: list[str]) -> None:
+ if sys.version_info < (3, 10):
+ errors.append(f"Python 3.10+ is required, found {sys.version.split()[0]}")
+
+ for distribution, expected in EXPECTED_DISTRIBUTIONS.items():
+ try:
+ actual = metadata.version(distribution)
+ except metadata.PackageNotFoundError:
+ errors.append(f"missing package: {distribution}=={expected}")
+ continue
+ print(f"{distribution}=={actual}")
+ if actual != expected:
+ errors.append(f"{distribution}: expected {expected}, found {actual}")
+
+ qwen_module = "transformers.models.qwen3_5.modeling_qwen3_5"
+ try:
+ qwen_spec = importlib.util.find_spec(qwen_module)
+ except (ImportError, ModuleNotFoundError):
+ qwen_spec = None
+ if qwen_spec is None:
+ errors.append(f"Transformers does not expose {qwen_module}")
+
+
+def _check_training_runtime(errors: list[str]) -> None:
+ """Confirm the bundled trainer resolves from the installed environment."""
+
+ for module in TRAINING_MODULES:
+ try:
+ spec = importlib.util.find_spec(module)
+ except (ImportError, ModuleNotFoundError) as exc:
+ errors.append(f"cannot resolve training module {module}: {exc}")
+ continue
+ if spec is None or spec.origin is None:
+ errors.append(f"missing training module: {module}")
+ continue
+ print(f"{module}: {spec.origin}")
+
+
+def _check_torch(require_gpu: bool, require_h20: bool, errors: list[str]) -> None:
+ try:
+ import torch
+ except Exception as exc:
+ errors.append(f"cannot import torch: {exc}")
+ return
+
+ print(f"torch=={torch.__version__}")
+ print(f"torch CUDA build: {torch.version.cuda}")
+ if not str(torch.__version__).startswith("2.10.0+cu129"):
+ errors.append(f"expected torch 2.10.0+cu129, found {torch.__version__}")
+ if torch.version.cuda != "12.9":
+ errors.append(f"expected a CUDA 12.9 PyTorch build, found {torch.version.cuda}")
+
+ if not torch.cuda.is_available():
+ print("CUDA device: unavailable")
+ if require_gpu or require_h20:
+ errors.append("CUDA is unavailable inside this process")
+ return
+
+ names: list[str] = []
+ for index in range(torch.cuda.device_count()):
+ name = torch.cuda.get_device_name(index)
+ capability = torch.cuda.get_device_capability(index)
+ names.append(name)
+ print(f"CUDA device {index}: {name} (compute capability {capability[0]}.{capability[1]})")
+
+ if require_h20 and not any("H20" in name.upper() for name in names):
+ errors.append(f"expected an NVIDIA H20, found: {', '.join(names)}")
+
+
+def _check_video_runtime(errors: list[str]) -> None:
+ executable = shutil.which("ffmpeg")
+ if executable is None:
+ errors.append("missing FFmpeg executable; install ffmpeg=7 from conda-forge")
+ else:
+ result = subprocess.run(
+ [executable, "-version"],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ version_line = result.stdout.splitlines()[0] if result.stdout else ""
+ print(version_line or f"ffmpeg: {executable}")
+ if result.returncode != 0:
+ errors.append(f"FFmpeg failed with exit code {result.returncode}")
+
+ try:
+ import torchcodec
+ except Exception as exc:
+ errors.append(f"cannot import torchcodec with FFmpeg runtime: {exc}")
+ else:
+ print(f"torchcodec runtime=={torchcodec.__version__}")
+
+
+def _check_model(model: Path, errors: list[str]) -> None:
+ model = model.expanduser().resolve()
+ if not model.is_dir():
+ errors.append(f"model directory does not exist: {model}")
+ return
+
+ present = {path.name for path in model.iterdir() if path.is_file()}
+ missing = sorted(REQUIRED_MODEL_FILES - present)
+ if missing:
+ errors.append(f"model is missing required files: {', '.join(missing)}")
+ return
+
+ index_path = model / "model.safetensors.index.json"
+ try:
+ index = json.loads(index_path.read_text(encoding="utf-8"))
+ weight_map = index["weight_map"]
+ shards = {str(name) for name in weight_map.values()}
+ except (OSError, ValueError, KeyError, TypeError) as exc:
+ errors.append(f"invalid model shard index: {exc}")
+ return
+
+ missing_shards = sorted(shard for shard in shards if not (model / shard).is_file())
+ if missing_shards:
+ errors.append(f"model is missing indexed weight shards: {', '.join(missing_shards)}")
+ return
+
+ try:
+ from transformers import AutoConfig, AutoProcessor
+
+ config = AutoConfig.from_pretrained(model, trust_remote_code=False)
+ AutoProcessor.from_pretrained(model, trust_remote_code=False)
+ except Exception as exc:
+ errors.append(f"Transformers cannot load checkpoint metadata: {exc}")
+ return
+
+ print(f"model: {model}")
+ print(f"model type: {config.model_type}")
+ print(f"indexed weight shards: {len(shards)}")
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ args = _arguments(argv)
+ errors: list[str] = []
+
+ _check_versions(errors)
+ if not args.evaluation_only:
+ _check_training_runtime(errors)
+ _check_torch(args.require_gpu, args.require_h20, errors)
+ _check_video_runtime(errors)
+ if args.model is not None:
+ _check_model(args.model, errors)
+
+ if errors:
+ print("\nEnvironment validation failed:", file=sys.stderr)
+ for error in errors:
+ print(f"- {error}", file=sys.stderr)
+ return 1
+
+ print("\nOraRL environment validation passed.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/code/scripts/check_release.py b/code/scripts/check_release.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f334e40be1e6ccd25cf8f5787e876780b0f7183
--- /dev/null
+++ b/code/scripts/check_release.py
@@ -0,0 +1,333 @@
+#!/usr/bin/env python3
+"""Fail closed when the OraRL public tree contains release hygiene hazards."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable, Sequence
+
+DEFAULT_MAX_BYTES = 5 * 1024 * 1024
+EVALUATION_JSONL_MAX_BYTES = 64 * 1024 * 1024
+_METHOD_TERMS = (
+ "".join(("c", "p", "p", "o")),
+ "".join(("g", "t", "p", "o")),
+ "".join(("build_", "g", "t")),
+ "".join(("lu", "ff", "y")),
+)
+# Benchmark evaluators name upstream annotation fields after ground truth, so
+# this prefix is only a tripwire outside the vendored evaluation runtime.
+_LEGACY_ORACLE_TERMS = ("".join(("g", "t", "_")),)
+_EXCLUDED_TERMS = _METHOD_TERMS + _LEGACY_ORACLE_TERMS
+_IGNORED_LOCAL_DIRECTORIES = {".git", "checkpoint"}
+_GENERATED_DIRECTORIES = {
+ ".nox",
+ ".tox",
+ ".venv",
+ ".mypy_cache",
+ ".pytest_cache",
+ ".ruff_cache",
+ "__pycache__",
+ "artifacts",
+ "build",
+ "cache",
+ "checkpoints",
+ "dist",
+ "htmlcov",
+ "logs",
+ "outputs",
+ "runs",
+ "venv",
+}
+_GENERATED_FILES = {".coverage", "orarl-eval-summary.json"}
+_ALLOWED_RELEASE_BINARIES = {
+ Path("assets/orarl-data-scaling.gif"),
+ Path("assets/orarl-hero.gif"),
+ Path("assets/orarl-method.gif"),
+ Path("assets/orarl-model-scaling.gif"),
+ Path("assets/orarl-teaser.mp4"),
+ Path("assets/paper-data-scaling.png"),
+ Path("assets/paper-framework.png"),
+ Path("assets/paper-model-scaling.png"),
+ Path("assets/paper-results.png"),
+ Path("orarl.pdf"),
+}
+_ARTIFACT_SUFFIXES = {
+ ".arrow",
+ ".avi",
+ ".bin",
+ ".ckpt",
+ ".jsonl",
+ ".mkv",
+ ".mov",
+ ".mp4",
+ ".npy",
+ ".npz",
+ ".parquet",
+ ".pt",
+ ".pth",
+ ".pyc",
+ ".safetensors",
+ ".so",
+ ".webm",
+}
+_RELEASE_BINARY_FILES = {
+ Path("assets/orarl-data-scaling.gif"),
+ Path("assets/orarl-hero.gif"),
+ Path("assets/orarl-method.gif"),
+ Path("assets/orarl-model-scaling.gif"),
+ Path("assets/orarl-teaser.mp4"),
+ Path("assets/paper-data-scaling.png"),
+ Path("assets/paper-framework.png"),
+ Path("assets/paper-model-scaling.png"),
+ Path("assets/paper-results.png"),
+ Path("orarl.pdf"),
+}
+_PRIVATE_PATH_PATTERNS = (
+ re.compile("/(?:" + "|".join(("mnt", "home", "Users")) + r")/[A-Za-z0-9._-]+(?:/[^\s'\"`]+)?"),
+ re.compile(
+ "/" + "data" + "/(?:" + "|".join(("home", "user", "users")) + r")/"
+ r"[A-Za-z0-9._-]+(?:/[^\s'\"`]+)?"
+ ),
+ re.compile(
+ "/(?:" + "|".join(("apd" + "cephfs" + r"[^/\s]*", "jizhi" + "cfs")) + r")(?:/[^\s'\"`]+)?"
+ ),
+ re.compile("/" + "root" + r"(?:/[^\s'\"`]+)?"),
+)
+_SECRET_PATTERNS = (
+ re.compile(
+ r"(?i)\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|password|secret)"
+ r"\b\s*[:=]\s*['\"]?[A-Za-z0-9_./+=-]{12,}"
+ ),
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
+ re.compile(r"\bhf_[A-Za-z0-9]{20,}\b"),
+ re.compile(r"://[^/\s:@]+:[^@\s/]+@"),
+)
+_PRIVATE_KEY_MARKER = "".join(("-" * 5, "BEGIN ", "PRIVATE", " KEY", "-" * 5))
+
+
+@dataclass(frozen=True, slots=True)
+class Finding:
+ """One release gate failure."""
+
+ path: Path
+ reason: str
+
+
+def _line_number(text: str, index: int) -> int:
+ return text.count("\n", 0, index) + 1
+
+
+def _text_findings(
+ path: Path,
+ text: str,
+ *,
+ check_excluded_terms: bool = True,
+ allow_ground_truth_names: bool = False,
+) -> Iterable[Finding]:
+ if check_excluded_terms:
+ lowered = text.casefold()
+ terms = _METHOD_TERMS if allow_ground_truth_names else _EXCLUDED_TERMS
+ for term in terms:
+ index = lowered.find(term)
+ if index >= 0:
+ yield Finding(
+ path,
+ f"excluded method term at line {_line_number(text, index)}",
+ )
+
+ for pattern in _PRIVATE_PATH_PATTERNS:
+ match = pattern.search(text)
+ if match:
+ yield Finding(
+ path,
+ f"private absolute path at line {_line_number(text, match.start())}",
+ )
+
+ if _PRIVATE_KEY_MARKER in text:
+ yield Finding(path, "private key material")
+ for pattern in _SECRET_PATTERNS:
+ match = pattern.search(text)
+ if match:
+ yield Finding(
+ path,
+ f"credential-like value at line {_line_number(text, match.start())}",
+ )
+
+
+def _ignored_generated_directory(root: Path, generated_root: Path) -> bool:
+ """Return whether root-level gitignore rules exclude generated state."""
+
+ ignore_file = root / ".gitignore"
+ if not ignore_file.is_file():
+ return False
+ try:
+ patterns = {
+ line.strip()
+ for line in ignore_file.read_text(encoding="utf-8").splitlines()
+ if line.strip() and not line.lstrip().startswith(("#", "!"))
+ }
+ except OSError:
+ return False
+ name = generated_root.name
+ if name.endswith(".egg-info") and "*.egg-info/" in patterns:
+ return True
+ return f"{name}/" in patterns or f"/{name}/" in patterns
+
+
+def check_release(root: Path, max_bytes: int = DEFAULT_MAX_BYTES) -> list[Finding]:
+ """Return every release hygiene problem under ``root``."""
+
+ root = root.expanduser().resolve()
+ findings: list[Finding] = []
+ reported_generated: set[Path] = set()
+ for path in sorted(root.rglob("*")):
+ relative = path.relative_to(root)
+ relative_text = relative.as_posix()
+ lowered_parts = tuple(part.casefold() for part in relative.parts)
+ release_binary = relative in _RELEASE_BINARY_FILES
+ release_eval_data = lowered_parts[:2] == ("data", "eval")
+ release_eval_runtime = lowered_parts[:2] == ("eval", "task")
+
+ if any(part in _IGNORED_LOCAL_DIRECTORIES for part in lowered_parts):
+ continue
+
+ generated_index = next(
+ (
+ index
+ for index, part in enumerate(lowered_parts)
+ if part in _GENERATED_DIRECTORIES or part.endswith(".egg-info")
+ ),
+ None,
+ )
+ if generated_index is not None:
+ generated_root = Path(*relative.parts[: generated_index + 1])
+ if _ignored_generated_directory(root, generated_root):
+ continue
+ if generated_root not in reported_generated:
+ findings.append(Finding(generated_root, "generated directory"))
+ reported_generated.add(generated_root)
+ continue
+
+ path_terms = _METHOD_TERMS if release_eval_runtime else _EXCLUDED_TERMS
+ if any(term in relative_text.casefold() for term in path_terms):
+ findings.append(Finding(relative, "excluded method term in path"))
+
+ if path.is_symlink():
+ target = os.readlink(path)
+ if not path.exists():
+ findings.append(Finding(relative, "broken symbolic link"))
+ else:
+ resolved_target = path.resolve()
+ try:
+ resolved_target.relative_to(root)
+ except ValueError:
+ findings.append(Finding(relative, "symbolic link escapes release tree"))
+ for pattern in _PRIVATE_PATH_PATTERNS:
+ if pattern.search(target):
+ findings.append(Finding(relative, "symbolic link contains a private path"))
+ break
+ continue
+
+ if path.is_dir():
+ if (
+ lowered_parts
+ and lowered_parts[0] == "data"
+ and len(lowered_parts) > 1
+ and not release_eval_data
+ ):
+ findings.append(Finding(relative, "generated root data directory"))
+ continue
+ if not path.is_file():
+ findings.append(Finding(relative, "unsupported filesystem entry"))
+ continue
+ if relative in _ALLOWED_RELEASE_BINARIES:
+ continue
+
+ if path.name.casefold() in _GENERATED_FILES:
+ findings.append(Finding(relative, "generated output file"))
+ if (
+ path.suffix.casefold() in _ARTIFACT_SUFFIXES
+ and not release_binary
+ and not (
+ release_eval_data
+ and path.suffix.casefold() == ".jsonl"
+ )
+ ):
+ findings.append(Finding(relative, "generated or binary artifact"))
+
+ size = path.stat().st_size
+ effective_max_bytes = (
+ max(max_bytes, EVALUATION_JSONL_MAX_BYTES)
+ if release_eval_data and path.suffix.casefold() == ".jsonl"
+ else max_bytes
+ )
+ if size > effective_max_bytes and not release_binary:
+ findings.append(
+ Finding(
+ relative,
+ f"file is too large ({size} bytes; limit {effective_max_bytes})",
+ )
+ )
+ continue
+ try:
+ text = path.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ if not release_binary:
+ findings.append(Finding(relative, "non-text file"))
+ continue
+ findings.extend(
+ _text_findings(
+ relative,
+ text,
+ check_excluded_terms=not release_eval_data,
+ allow_ground_truth_names=release_eval_runtime,
+ )
+ )
+ return findings
+
+
+def create_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "root",
+ nargs="?",
+ default=str(Path(__file__).resolve().parents[1]),
+ help="Release tree to inspect (default: OraRL source root).",
+ )
+ parser.add_argument(
+ "--max-bytes",
+ type=int,
+ default=DEFAULT_MAX_BYTES,
+ help="Maximum permitted size for one source file.",
+ )
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ namespace = create_parser().parse_args(argv)
+ root = Path(namespace.root).expanduser()
+ if not root.is_dir():
+ print(f"ERROR: release root is not a directory: {root}")
+ return 2
+ if namespace.max_bytes <= 0:
+ print("ERROR: --max-bytes must be positive")
+ return 2
+
+ findings = check_release(root, namespace.max_bytes)
+ if findings:
+ for finding in findings:
+ print(f"ERROR: {finding.path}: {finding.reason}")
+ print(f"release hygiene failed with {len(findings)} finding(s)")
+ return 1
+ file_count = sum(1 for path in root.rglob("*") if path.is_file())
+ print(f"release hygiene passed ({file_count} files)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/code/scripts/create_conda_env.sh b/code/scripts/create_conda_env.sh
new file mode 100644
index 0000000000000000000000000000000000000000..8f97b3a819f627eda6a07c4affff6d6a1859f974
--- /dev/null
+++ b/code/scripts/create_conda_env.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ORARL_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+
+ENV_NAME="${ORARL_CONDA_ENV:-orarl}"
+PYTORCH_INDEX="https://download.pytorch.org/whl/cu129"
+EVALUATION_ONLY=0
+
+usage() {
+ cat <<'EOF'
+Usage: bash scripts/create_conda_env.sh [options]
+
+Create the pinned OraRL CUDA 12.9 environment. This checkout ships both the
+training runtime and the evaluators, so no external runtime is required. Use
+--evaluation-only to skip the training-side environment validation.
+
+Options:
+ --name ENV_NAME Conda environment name (default: orarl).
+ --evaluation-only Validate the environment for evaluation only.
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --name)
+ if [[ $# -lt 2 || -z "$2" ]]; then
+ echo "ERROR: --name requires a non-empty environment name." >&2
+ exit 2
+ fi
+ ENV_NAME="$2"
+ shift 2
+ ;;
+ --evaluation-only)
+ EVALUATION_ONLY=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "ERROR: unknown argument: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if ! command -v conda >/dev/null 2>&1; then
+ echo "ERROR: conda is not available in PATH." >&2
+ exit 1
+fi
+
+if [[ ! -d "${ORARL_ROOT}/verl" ]]; then
+ echo "ERROR: the bundled training runtime is missing at ${ORARL_ROOT}/verl." >&2
+ echo "Clone the full repository instead of copying individual directories." >&2
+ exit 1
+fi
+
+if command -v nvidia-smi >/dev/null 2>&1; then
+ echo "Detected cluster GPU(s):"
+ nvidia-smi --query-gpu=name,driver_version --format=csv,noheader || true
+ echo "CUDA 12.9 GA officially requires Linux driver 575.51.03 or newer."
+else
+ echo "WARNING: nvidia-smi is unavailable; run the GPU check on an allocated H20 node." >&2
+fi
+
+if conda run -n "${ENV_NAME}" python -c "pass" >/dev/null 2>&1; then
+ echo "ERROR: conda environment '${ENV_NAME}' already exists." >&2
+ echo "Choose another name with --name or remove/update it explicitly." >&2
+ exit 1
+fi
+
+conda env create \
+ --name "${ENV_NAME}" \
+ --file "${ORARL_ROOT}/environment.yml"
+
+run_in_env() {
+ conda run --no-capture-output -n "${ENV_NAME}" "$@"
+}
+
+run_in_env python -m pip install --upgrade \
+ "pip==26.0.1" \
+ "setuptools==82.0.1" \
+ "wheel==0.46.3"
+
+# Install the CUDA build explicitly. Generic PyPI resolves PyTorch 2.10 to the
+# CUDA 12.8 wheel, which is not the stack validated with vLLM 0.19.1 here.
+run_in_env python -m pip install \
+ "torch==2.10.0+cu129" \
+ "torchvision==0.25.0+cu129" \
+ "torchaudio==2.10.0+cu129" \
+ --index-url "${PYTORCH_INDEX}"
+
+# PyTorch must be importable before building FlashAttention.
+run_in_env python -m pip install \
+ "flash-attn==2.8.3" \
+ --no-build-isolation
+
+run_in_env python -m pip install \
+ "vllm==0.19.1" \
+ --extra-index-url "${PYTORCH_INDEX}"
+
+run_in_env python -m pip install \
+ --requirement "${ORARL_ROOT}/requirements-cu129.txt"
+
+# One editable install covers the CLIs, the trainer runtime, and the evaluators.
+# --no-deps keeps it from replacing the GPU stack pinned above.
+run_in_env python -m pip install --no-deps --editable "${ORARL_ROOT}"
+run_in_env python -m pip install "pytest" "ruff"
+
+run_in_env bash "${ORARL_ROOT}/scripts/install_conda_runtime_hook.sh"
+CHECK_ARGUMENTS=()
+if [[ "${EVALUATION_ONLY}" -eq 1 ]]; then
+ CHECK_ARGUMENTS+=(--evaluation-only)
+fi
+run_in_env bash -c \
+ 'source "${CONDA_PREFIX}/etc/conda/activate.d/orarl-runtime.sh"; shift; exec python "$@"' \
+ _ "${ORARL_ROOT}/scripts/check_environment.py" "${CHECK_ARGUMENTS[@]}"
+
+cat < bool:
+ try:
+ path.resolve(strict=False).relative_to(directory.resolve(strict=False))
+ except ValueError:
+ return False
+ return True
+
+
+def _required_directory(path: Path, label: str) -> Path:
+ resolved = path.expanduser().resolve()
+ if not resolved.is_dir():
+ raise DiscoveryError(f"{label} directory does not exist: {resolved}")
+ return resolved
+
+
+def _walk_named_files(root: Path, names: Iterable[str]) -> list[Path]:
+ wanted = set(names)
+ matches: list[Path] = []
+ for current, directories, filenames in os.walk(root, followlinks=False):
+ directories[:] = sorted(
+ name for name in directories if name.casefold() not in _SKIPPED_DIRECTORY_NAMES
+ )
+ current_path = Path(current)
+ for filename in sorted(filenames):
+ if filename in wanted:
+ matches.append((current_path / filename).resolve())
+ return sorted(set(matches), key=str)
+
+
+def _walk_suffix_files(root: Path, suffixes: Iterable[str]) -> list[Path]:
+ wanted = {suffix.casefold() for suffix in suffixes}
+ matches: list[Path] = []
+ for current, directories, filenames in os.walk(root, followlinks=False):
+ directories[:] = sorted(
+ name for name in directories if name.casefold() not in _SKIPPED_DIRECTORY_NAMES
+ )
+ current_path = Path(current)
+ for filename in sorted(filenames):
+ path = current_path / filename
+ if path.suffix.casefold() in wanted:
+ matches.append(path.resolve())
+ return sorted(set(matches), key=str)
+
+
+def _one_file(label: str, tiers: Sequence[Sequence[Path]]) -> Path:
+ attempted: list[str] = []
+ for tier in tiers:
+ candidates = sorted(
+ {path.resolve() for path in tier if path.is_file()},
+ key=str,
+ )
+ attempted.extend(str(path) for path in tier)
+ if len(candidates) > 1:
+ rendered = ", ".join(str(path) for path in candidates)
+ raise DiscoveryError(f"ambiguous {label}; found: {rendered}")
+ if candidates:
+ return candidates[0]
+ raise DiscoveryError(f"required {label} was not found; tried: {', '.join(attempted)}")
+
+
+def _runtime_annotation(
+ runtime_root: Path,
+ source_root: Path,
+ label: str,
+ name_tiers: Sequence[Sequence[str]],
+) -> Path:
+ valid_data = runtime_root / "eval" / "data" / "valid_data"
+ tiers: list[list[Path]] = []
+ for names in name_tiers:
+ tiers.append([valid_data / name for name in names])
+ for names in name_tiers:
+ tiers.append(_walk_named_files(source_root, names))
+ return _one_file(label, tiers)
+
+
+def _named_annotation(root: Path, label: str, names: Sequence[str]) -> Path:
+ return _one_file(label, [_walk_named_files(root, names)])
+
+
+def _first_directory(label: str, tiers: Sequence[Sequence[Path]]) -> Path:
+ attempted: list[str] = []
+ for tier in tiers:
+ candidates = sorted(
+ {path.resolve() for path in tier if path.is_dir()},
+ key=str,
+ )
+ attempted.extend(str(path) for path in tier)
+ if len(candidates) > 1:
+ rendered = ", ".join(str(path) for path in candidates)
+ raise DiscoveryError(f"ambiguous {label}; found: {rendered}")
+ if candidates:
+ return candidates[0]
+ raise DiscoveryError(f"required {label} directory was not found; tried: {', '.join(attempted)}")
+
+
+def _cheap_expected_count(path: Path) -> int | None:
+ suffix = path.suffix.casefold()
+ if suffix in {".jsonl", ".ndjson"}:
+ with path.open("r", encoding="utf-8") as handle:
+ return sum(bool(line.strip()) for line in handle)
+ if suffix == ".tsv":
+ with path.open("r", encoding="utf-8", errors="replace", newline="") as handle:
+ csv.field_size_limit(sys.maxsize)
+ return sum(1 for _row in csv.DictReader(handle, delimiter="\t"))
+ if suffix == ".parquet":
+ try:
+ import pyarrow.parquet as parquet
+ except ImportError:
+ return None
+ try:
+ return int(parquet.ParquetFile(path).metadata.num_rows)
+ except (OSError, ValueError):
+ return None
+ return None
+
+
+def _mindcube_annotation(root: Path) -> Path:
+ candidates = _walk_suffix_files(root, (".parquet",))
+ if not candidates:
+ raise DiscoveryError(
+ "official MindCube-Tiny parquet was not found under "
+ f"{root}; expected {_MINDCUBE_EXPECTED_SAMPLES} rows"
+ )
+
+ counted = [(path, _cheap_expected_count(path)) for path in candidates]
+ official = [
+ path for path, count in counted if count == _MINDCUBE_EXPECTED_SAMPLES
+ ]
+ if len(official) == 1:
+ return official[0]
+ if len(official) > 1:
+ rendered = ", ".join(str(path) for path in official)
+ raise DiscoveryError(
+ "ambiguous official MindCube-Tiny parquet; "
+ f"found multiple {_MINDCUBE_EXPECTED_SAMPLES}-row files: {rendered}"
+ )
+
+ canonical_unreadable = [
+ path
+ for path, count in counted
+ if count is None and path.name == "combined-00000-of-00001.parquet"
+ ]
+ if len(canonical_unreadable) == 1:
+ return canonical_unreadable[0]
+
+ rendered = ", ".join(
+ f"{path} ({'unknown' if count is None else count} rows)"
+ for path, count in counted
+ )
+ raise DiscoveryError(
+ f"official MindCube-Tiny requires {_MINDCUBE_EXPECTED_SAMPLES} rows; "
+ f"found: {rendered}"
+ )
+
+
+def _revsi_annotation(root: Path) -> Path:
+ annotation = root / "all_frame" / "test-00000-of-00001.parquet"
+ if not annotation.is_file():
+ raise DiscoveryError(
+ "official ReVSI all-frame annotation was not found; expected "
+ f"{annotation}"
+ )
+ count = _cheap_expected_count(annotation)
+ if count is not None and count != _REVSI_EXPECTED_SAMPLES:
+ raise DiscoveryError(
+ f"official ReVSI all-frame split requires {_REVSI_EXPECTED_SAMPLES} rows; "
+ f"found {count} in {annotation}"
+ )
+ return annotation
+
+
+def _source_record(
+ *,
+ task: str,
+ split: str,
+ family: str,
+ adapter: str,
+ annotation: Path,
+ source_url: str,
+ evaluation: Mapping[str, Any],
+ preprocessing: Mapping[str, Any] | None,
+ legacy_environment: Mapping[str, Any] | None,
+ media_roots: Mapping[str, Path] | None,
+ preprocessed_roots: Mapping[str, Path] | None,
+ authorized: bool,
+ expected_count: int | None = None,
+) -> dict[str, Any]:
+ record: dict[str, Any] = {
+ "benchmark": task,
+ "eval_task": task,
+ "split": split,
+ "family": family,
+ "adapter": adapter,
+ "annotation_input": str(annotation.resolve()),
+ "expected_count": (
+ expected_count
+ if expected_count is not None
+ else _cheap_expected_count(annotation)
+ ),
+ "license": _UPSTREAM_TERMS,
+ "source_url": source_url,
+ "redistribution_authorized": authorized,
+ "evaluation": dict(evaluation),
+ }
+ if preprocessing:
+ record["preprocessing"] = dict(preprocessing)
+ if legacy_environment:
+ record["legacy_environment"] = dict(legacy_environment)
+ if media_roots:
+ record["media_roots"] = {
+ name: str(path.resolve()) for name, path in sorted(media_roots.items())
+ }
+ if preprocessed_roots:
+ record["preprocessed_roots"] = {
+ name: str(path.resolve()) for name, path in sorted(preprocessed_roots.items())
+ }
+ return record
+
+
+def _video_qa_records(
+ data_root: Path,
+ runtime_root: Path,
+ *,
+ authorized: bool,
+) -> list[dict[str, Any]]:
+ records: list[dict[str, Any]] = []
+ for spec in _VIDEO_QA_SPECS:
+ task = str(spec["task"])
+ source_root = _required_directory(data_root / str(spec["folder"]), str(spec["folder"]))
+ annotation = _runtime_annotation(
+ runtime_root,
+ source_root,
+ f"{task} annotation",
+ spec["annotation_tiers"],
+ )
+ if task == "videomme":
+ video_root = _first_directory(
+ "videomme videos",
+ (
+ (source_root / "data",),
+ (source_root / "videos",),
+ (source_root,),
+ ),
+ )
+ elif task == "mvbench":
+ # MVBench annotations already include their dataset subdirectory
+ # (for example, ``./star/...`` and ``./clevrer/...``). A separate
+ # ``videos/`` directory may exist but is not the common parent of
+ # those references.
+ video_root = source_root
+ else:
+ video_root = source_root / "videos"
+ if not video_root.is_dir():
+ video_root = source_root
+ media_roots: dict[str, Path] = {"videos": video_root}
+ if task == "longvideobench":
+ subtitle_root = source_root / "subtitles"
+ media_roots["subtitles"] = subtitle_root if subtitle_root.is_dir() else source_root
+
+ preprocessed_roots: dict[str, Path] = {}
+ if task in {"videomme", "videommev2"}:
+ global_preprocessed = data_root / "preprocessed_videos"
+ prepared_name = (
+ "videomme_preprocessed_384f_262k_total0"
+ if task == "videomme"
+ else "videommev2_preprocessed_384f_262k_total0"
+ )
+ prepared = _first_directory(
+ f"{task} preprocessed videos",
+ (
+ (source_root / "preprocessed_videos_384f_262k_total0",),
+ (source_root / "preprocessed_videos",),
+ (global_preprocessed / prepared_name,),
+ (global_preprocessed / task,),
+ (global_preprocessed,),
+ ),
+ )
+ preprocessed_roots["default"] = prepared
+
+ preprocessing = {**_COMMON_VIDEO_SETTINGS, **spec.get("preprocessing", {})}
+ prefix = {
+ "videomme": "VIDEOMME",
+ "videommev2": "VIDEOMMEV2",
+ "mvbench": "MVBENCH",
+ "mmvu": "MMVU",
+ "videoholmes": "VIDEOHOLMES",
+ "longvideobench": "LONGVIDEOBENCH",
+ "mlvu": "MLVU",
+ }[task]
+ legacy = {f"{prefix}_{key.upper()}": value for key, value in _COMMON_VIDEO_SETTINGS.items()}
+ legacy.update(spec.get("legacy", {}))
+ records.append(
+ _source_record(
+ task=task,
+ split=str(spec["split"]),
+ family="video_qa",
+ adapter="generic",
+ annotation=annotation,
+ source_url=str(spec["source_url"]),
+ evaluation=spec["evaluation"],
+ preprocessing=preprocessing,
+ legacy_environment=legacy,
+ media_roots=media_roots,
+ preprocessed_roots=preprocessed_roots,
+ authorized=authorized,
+ )
+ )
+ return records
+
+
+def _spatial_intelligence_records(
+ data_root: Path,
+ runtime_root: Path,
+ *,
+ authorized: bool,
+) -> list[dict[str, Any]]:
+ vsi_root = _required_directory(data_root / "VSI-Bench", "VSI-Bench")
+ vsi_annotation = _runtime_annotation(
+ runtime_root,
+ vsi_root,
+ "vsi annotation",
+ (
+ ("vsibench_preprocessed_128f_16M.jsonl",),
+ ("vsibench.jsonl", "vsibench.json"),
+ ),
+ )
+ global_preprocessed = data_root / "preprocessed_videos"
+ vsi_preprocessed = _first_directory(
+ "vsi preprocessed videos",
+ (
+ (
+ vsi_root / "preprocessed_videos_128f_16M",
+ vsi_root / "preprocessed_videos",
+ ),
+ (
+ global_preprocessed / "vsibench_preprocessed_128f_16M",
+ global_preprocessed / "vsi",
+ global_preprocessed / "VSI-Bench",
+ ),
+ (global_preprocessed,),
+ ),
+ )
+ records = [
+ _source_record(
+ task="vsi",
+ split="test",
+ family="spatial_intelligence",
+ adapter="generic",
+ annotation=vsi_annotation,
+ source_url="https://huggingface.co/datasets/nyu-visionx/VSI-Bench",
+ evaluation={
+ "prompt_profile": "vsi_default",
+ "parser_profile": "vsi_answer",
+ "metric_profile": "vsi_official",
+ },
+ preprocessing={
+ "fps": 2,
+ "max_frames": 128,
+ "video_min_pixels": 65536,
+ "video_total_pixels": 16777216,
+ },
+ legacy_environment={
+ "VSI_SETTING": (
+ "video128-16M-video-f128-fps2-min65536-maxnone-total16777216-"
+ "vsibench_preprocessed_128f_16M"
+ ),
+ "VSI_BATCH_SIZE": 16,
+ "VSI_MAX_MODEL_LEN": 32768,
+ "VSI_MAX_NEW_TOKENS": 1024,
+ "VSI_EXPECTED_SAMPLES": 5130,
+ },
+ media_roots={"videos": vsi_root},
+ preprocessed_roots={"default": vsi_preprocessed},
+ authorized=authorized,
+ )
+ ]
+
+ mmsi_root = _required_directory(data_root / "MMSI-Bench", "MMSI-Bench")
+ mmsi_annotation = _named_annotation(
+ mmsi_root,
+ "mmsi annotation",
+ ("MMSI_bench.tsv",),
+ )
+ records.append(
+ _source_record(
+ task="mmsi",
+ split="test",
+ family="spatial_intelligence",
+ adapter="mmsi",
+ annotation=mmsi_annotation,
+ source_url="https://huggingface.co/datasets/RunsenXu/MMSI-Bench",
+ evaluation={
+ "prompt_profile": "mmsi_default",
+ "parser_profile": "multiple_choice",
+ "metric_profile": "accuracy",
+ },
+ preprocessing={"image_min_pixels": 4096, "image_max_pixels": 262144},
+ legacy_environment={
+ "MMSI_BACKEND": "transformers",
+ "MMSI_MAX_IMAGES": 0,
+ "MMSI_IMAGE_MIN_PIXELS": 4096,
+ "MMSI_IMAGE_MAX_PIXELS": 262144,
+ "MMSI_ENABLE_THINKING": False,
+ },
+ media_roots=None,
+ preprocessed_roots=None,
+ authorized=authorized,
+ )
+ )
+
+ mindcube_root = _required_directory(data_root / "MindCube-Tiny", "MindCube-Tiny")
+ mindcube_annotation = _mindcube_annotation(mindcube_root)
+ records.append(
+ _source_record(
+ task="mindcube",
+ split="test",
+ family="spatial_intelligence",
+ adapter="mindcube",
+ annotation=mindcube_annotation,
+ source_url=(
+ "https://huggingface.co/datasets/oscarqjh/MindCube_lmmseval/"
+ "tree/7dd2725d9bd4149f2aad00a9843f72a3824da003"
+ ),
+ evaluation={
+ "prompt_profile": "mindcube_official",
+ "parser_profile": "multiple_choice",
+ "metric_profile": "micro_accuracy",
+ "aggregation": "micro",
+ "expected_group_counts": {
+ "rotation": 200,
+ "among": 600,
+ "around": 250,
+ },
+ },
+ preprocessing={"max_images": 4},
+ legacy_environment={
+ "MINDCUBE_MAX_IMAGES": 4,
+ "MINDCUBE_BATCH_SIZE": 16,
+ "MINDCUBE_MAX_MODEL_LEN": 32768,
+ "MINDCUBE_MAX_NEW_TOKENS": 1024,
+ },
+ media_roots={"images": mindcube_root},
+ preprocessed_roots=None,
+ authorized=authorized,
+ )
+ )
+
+ revsi_root = _required_directory(data_root / "ReVSI", "ReVSI")
+ revsi_annotation = _revsi_annotation(revsi_root)
+ revsi_frame_root = revsi_annotation.parent
+ records.append(
+ _source_record(
+ task="revsi",
+ split="test",
+ family="spatial_intelligence",
+ adapter="revsi",
+ annotation=revsi_annotation,
+ source_url="https://arxiv.org/abs/2605.25979",
+ evaluation={
+ "prompt_profile": "revsi_official",
+ "parser_profile": "revsi_answer",
+ "metric_profile": "revsi_macro_average",
+ "aggregation": "macro_by_question_type",
+ "frame_protocol": "native_all_frame",
+ },
+ preprocessing={
+ "fps": 2,
+ "max_frames": 128,
+ "exact_nframes": True,
+ "video_min_pixels": 65536,
+ "video_total_pixels": 16777216,
+ },
+ legacy_environment={
+ "REVSI_SETTING": (
+ "native-all-f128-exacttrue-fps2-min65536-"
+ "maxnone-total16777216"
+ ),
+ "REVSI_FRAME_BUDGET": "all",
+ "REVSI_MAX_FRAMES": 128,
+ "REVSI_EXACT_NFRAMES": True,
+ "REVSI_FPS": 2,
+ "REVSI_VIDEO_MIN_PIXELS": 65536,
+ "REVSI_VIDEO_TOTAL_PIXELS": 16777216,
+ "REVSI_BATCH_SIZE": 16,
+ "REVSI_MAX_MODEL_LEN": 32768,
+ "REVSI_MAX_NEW_TOKENS": 64,
+ "REVSI_GPU_MEMORY_UTILIZATION": 0.9,
+ "REVSI_EXPECTED_SAMPLES": _REVSI_EXPECTED_SAMPLES,
+ "REVSI_ENABLE_THINKING": False,
+ },
+ media_roots={"videos": revsi_frame_root},
+ preprocessed_roots=None,
+ authorized=authorized,
+ )
+ )
+ return records
+
+
+def _structured_records(
+ data_root: Path,
+ runtime_root: Path,
+ *,
+ authorized: bool,
+) -> list[dict[str, Any]]:
+ records: list[dict[str, Any]] = []
+ one_thinker_root = _required_directory(
+ data_root / "OneThinker-eval",
+ "OneThinker-eval",
+ )
+ grounding_root = _required_directory(
+ data_root / "Spatial-Grounding",
+ "Spatial-Grounding",
+ )
+ grounding_image_root = _first_directory(
+ "Spatial Grounding images",
+ (
+ (
+ one_thinker_root / "Refcoco",
+ one_thinker_root / "RefCOCO",
+ ),
+ (
+ grounding_root / "train2014",
+ grounding_root / "Refcoco",
+ grounding_root / "RefCOCO",
+ ),
+ (grounding_root,),
+ ),
+ )
+ runtime_grounding_root = (
+ runtime_root / "eval" / "task" / "spatial_grounding" / "rec_jsons_processed"
+ )
+ grounding_splits = (
+ ("refcoco_val", "refcoco_val.json", "refcoco-val"),
+ ("refcoco_test_a", "refcoco_testA.json", "refcoco-testA"),
+ ("refcoco_test_b", "refcoco_testB.json", "refcoco-testB"),
+ ("refcocop_val", "refcocop_val.json", "refcoco+-val"),
+ ("refcocop_test_a", "refcocop_testA.json", "refcoco+-testA"),
+ ("refcocop_test_b", "refcocop_testB.json", "refcoco+-testB"),
+ ("refcocog_val", "refcocog_val.json", "refcocog-val"),
+ ("refcocog_test", "refcocog_test.json", "refcocog-test"),
+ )
+ grounding_profile = {
+ "prompt_profile": "qwen_native",
+ "parser_profile": "norm1000_bbox",
+ "metric_profile": "refcoco_iou",
+ }
+ grounding_legacy = {
+ "SPATIAL_GROUNDING_DATASETS": ",".join(
+ evaluator_name for _split, _filename, evaluator_name in grounding_splits
+ ),
+ "SPATIAL_GROUNDING_PROMPT_STYLE": "qwen_native",
+ "SPATIAL_GROUNDING_COORD_SYSTEM": "norm1000",
+ "SPATIAL_GROUNDING_BBOX_SELECT": "first",
+ "SPATIAL_GROUNDING_MIN_TOKENS": 64,
+ "SPATIAL_GROUNDING_TOTAL_TOKENS": 1024,
+ "SPATIAL_GROUNDING_MAX_NEW_TOKENS": 1024,
+ }
+ for split, filename, _evaluator_name in grounding_splits:
+ jsonl_filename = f"{Path(filename).stem}.jsonl"
+ annotation = _one_file(
+ f"spatial_grounding/{split} annotation",
+ (
+ (runtime_grounding_root / filename,),
+ (runtime_grounding_root / jsonl_filename,),
+ _walk_named_files(grounding_root, (filename,)),
+ _walk_named_files(grounding_root, (jsonl_filename,)),
+ ),
+ )
+ records.append(
+ _source_record(
+ task="spatial_grounding",
+ split=split,
+ family="spatial_grounding",
+ adapter="spatial_grounding",
+ annotation=annotation,
+ source_url="https://huggingface.co/datasets/OneThink/OneThinker-eval",
+ evaluation=grounding_profile,
+ preprocessing={"coordinate_system": "norm1000"},
+ legacy_environment=grounding_legacy,
+ media_roots={"images": grounding_image_root},
+ preprocessed_roots=None,
+ authorized=authorized,
+ )
+ )
+
+ one_thinker_specs = (
+ (
+ "tracking",
+ "got10k",
+ "eval_got10k.json",
+ "tracking",
+ {
+ "prompt_profile": "tracking_default",
+ "parser_profile": "tracking_boxes",
+ "metric_profile": "got10k_ao",
+ },
+ {
+ "TRACKING_DATASETS": "eval_got10k",
+ "TRACKING_MAX_FRAMES": 32,
+ "TRACKING_FPS": 1,
+ "TRACKING_VIDEO_MIN_PIXELS": 4096,
+ "TRACKING_VIDEO_MAX_PIXELS": 786432,
+ "TRACKING_VIDEO_TOTAL_PIXELS": 8388608,
+ "TRACKING_MAX_NEW_TOKENS": 8192,
+ "TRACKING_PROMPT_MODE": "default",
+ },
+ ),
+ (
+ "stvg",
+ "stvg",
+ "eval_stvg.json",
+ "spatial_temporal_grounding",
+ {
+ "prompt_profile": "train_stvg",
+ "parser_profile": "temporal_spatial_boxes",
+ "metric_profile": "stvg_official",
+ },
+ {
+ "STVG_DATASETS": "eval_stvg",
+ "STVG_MAX_FRAMES": 128,
+ "STVG_FPS": 2,
+ "STVG_VIDEO_MIN_PIXELS": 65536,
+ "STVG_VIDEO_MAX_PIXELS": 393216,
+ "STVG_VIDEO_TOTAL_PIXELS": 10485760,
+ "STVG_MAX_NEW_TOKENS": 2048,
+ "STVG_PROMPT_MODE": "train_stvg",
+ },
+ ),
+ )
+ for task, split, filename, family, evaluation, legacy in one_thinker_specs:
+ annotation = _named_annotation(
+ one_thinker_root,
+ f"{task}/{split} annotation",
+ (filename, f"{Path(filename).stem}.jsonl"),
+ )
+ records.append(
+ _source_record(
+ task=task,
+ split=split,
+ family=family,
+ adapter="one_thinker",
+ annotation=annotation,
+ source_url="https://huggingface.co/datasets/OneThink/OneThinker-eval",
+ evaluation=evaluation,
+ preprocessing={
+ key.casefold(): value
+ for key, value in legacy.items()
+ if key.endswith(
+ (
+ "_FPS",
+ "_MAX_FRAMES",
+ "_VIDEO_MIN_PIXELS",
+ "_VIDEO_MAX_PIXELS",
+ "_VIDEO_TOTAL_PIXELS",
+ )
+ )
+ },
+ legacy_environment=legacy,
+ media_roots={"default": one_thinker_root},
+ preprocessed_roots=None,
+ authorized=authorized,
+ )
+ )
+
+ segmentation_splits = (
+ ("refcoco", "eval_seg_refcoco.json"),
+ ("refcocop", "eval_seg_refcocop.json"),
+ ("refcocog", "eval_seg_refcocog.json"),
+ ("mevis", "eval_seg_mevis.json"),
+ ("reasonvos", "eval_seg_reasonvos.json"),
+ )
+ segmentation_legacy = {
+ "SEGMENTATION_DATASETS": ",".join(split for split, _name in segmentation_splits),
+ "SEGMENTATION_DATA_TYPE": "all",
+ "SEGMENTATION_PROMPT_MODE": "train_seg",
+ "SEGMENTATION_ENABLE_THINKING": False,
+ "SEGMENTATION_MAX_FRAMES": 128,
+ "SEGMENTATION_FPS": 2,
+ "SEGMENTATION_VIDEO_READER": "decord",
+ "SEGMENTATION_VIDEO_MIN_PIXELS": 4096,
+ "SEGMENTATION_VIDEO_MAX_PIXELS": 262144,
+ "SEGMENTATION_VIDEO_TOTAL_PIXELS": 16777216,
+ "SEGMENTATION_MAX_PIXELS_IMAGE": 1048576,
+ "SEGMENTATION_MIN_PIXELS_IMAGE": 4096,
+ "SEGMENTATION_BATCH_SIZE": 16,
+ "SEGMENTATION_MAX_MODEL_LEN": 32768,
+ "SEGMENTATION_MAX_NEW_TOKENS": 1024,
+ "SEGMENTATION_GPU_MEM_UTIL": 0.85,
+ "SEGMENTATION_SEED": 42,
+ "SEGMENTATION_RUN_SAM2": False,
+ "SEGMENTATION_SETTING": (
+ "segmentation-refcoco_refcocop_refcocog_mevis_reasonvos-"
+ "train_seg-f128-fps2-min4096-max262144-total16777216-"
+ "readerdecord-new1024"
+ ),
+ }
+ for split, filename in segmentation_splits:
+ annotation = _named_annotation(
+ one_thinker_root,
+ f"segmentation/{split} annotation",
+ (filename, f"{Path(filename).stem}.jsonl"),
+ )
+ records.append(
+ _source_record(
+ task="segmentation",
+ split=split,
+ family="segmentation",
+ adapter="one_thinker",
+ annotation=annotation,
+ source_url="https://huggingface.co/datasets/OneThink/OneThinker-eval",
+ evaluation={
+ "prompt_profile": "train_seg",
+ "parser_profile": "sam2_prompt",
+ "metric_profile": "segmentation_official",
+ "oracle_profile": "segmentation_rle",
+ "oracle_location": "task_payload.segmentation_output",
+ },
+ preprocessing={
+ "fps": 2,
+ "max_frames": 128,
+ "video_reader": "decord",
+ "video_min_pixels": 4096,
+ "video_max_pixels": 262144,
+ "video_total_pixels": 16777216,
+ },
+ legacy_environment=segmentation_legacy,
+ media_roots={"default": one_thinker_root},
+ preprocessed_roots=None,
+ authorized=authorized,
+ expected_count=_SEGMENTATION_EXPECTED_SAMPLES[split],
+ )
+ )
+
+ timelens_root = _required_directory(data_root / "TimeLens-Bench", "TimeLens-Bench")
+ timelens_specs = (
+ (
+ "charades_timelens",
+ "charades-timelens",
+ 4,
+ ((timelens_root / "video_shards" / "charades",),),
+ ),
+ (
+ "activitynet_timelens",
+ "activitynet-timelens",
+ 4,
+ ((timelens_root / "video_shards" / "activitynet",),),
+ ),
+ (
+ "qvhighlights_timelens",
+ "qvhighlights-timelens",
+ 4,
+ (
+ (timelens_root / "video_shards" / "qvhighlights",),
+ (data_root / "qvhighlights-videos",),
+ (timelens_root / "qvhighlights-videos",),
+ ),
+ ),
+ )
+ timelens_datasets = ",".join(split for split, _stem, _fps, _roots in timelens_specs)
+ common_timelens_environment = {
+ "TIMELENS_DATASETS": timelens_datasets,
+ "TIMELENS_ENABLE_THINKING": False,
+ "TIMELENS_FPS": 4,
+ "TIMELENS_MIN_TOKENS": 1,
+ "TIMELENS_MAX_FRAMES": 2048,
+ "TIMELENS_MAX_PIXELS": 409600,
+ "TIMELENS_TOTAL_TOKENS": 128000,
+ "TIMELENS_MAX_NEW_TOKENS": 128,
+ "TIMELENS_PROMPT_MODE": "same",
+ "TIMELENS_STOP_AFTER_ANSWER": True,
+ "TIMELENS_NUM_WORKERS": 2,
+ }
+ for split, stem, fps, video_root_tiers in timelens_specs:
+ annotation = _named_annotation(
+ timelens_root,
+ f"temporal_grounding/{split} annotation",
+ (f"{stem}.json", f"{stem}.jsonl"),
+ )
+ video_root = _first_directory(f"{split} videos", video_root_tiers)
+ records.append(
+ _source_record(
+ task="temporal_grounding",
+ split=split,
+ family="temporal_grounding",
+ adapter="timelens",
+ annotation=annotation,
+ source_url="https://huggingface.co/datasets/TencentARC/TimeLens-Bench",
+ evaluation={
+ "prompt_profile": "timelens_same",
+ "parser_profile": "temporal_spans",
+ "metric_profile": "temporal_iou",
+ },
+ preprocessing={
+ "fps": fps,
+ "min_tokens": 1,
+ "max_frames": 2048,
+ "max_pixels": 409600,
+ "total_tokens": 128000,
+ },
+ legacy_environment=common_timelens_environment,
+ media_roots={"videos": video_root},
+ preprocessed_roots=None,
+ authorized=authorized,
+ )
+ )
+ return records
+
+
+def discover_eval_sources(
+ data_root: str | os.PathLike[str],
+ runtime_root: str | os.PathLike[str],
+ *,
+ redistribution_authorized: bool = False,
+) -> list[dict[str, Any]]:
+ """Discover the fixed paper suite without scanning unrelated data trees."""
+
+ data = _required_directory(Path(data_root), "data root")
+ runtime = _required_directory(Path(runtime_root), "runtime root")
+ records = [
+ *_video_qa_records(data, runtime, authorized=redistribution_authorized),
+ *_spatial_intelligence_records(data, runtime, authorized=redistribution_authorized),
+ *_structured_records(
+ data,
+ runtime,
+ authorized=redistribution_authorized,
+ ),
+ ]
+ records.sort(key=lambda item: (str(item["benchmark"]), str(item["split"])))
+ observed_tasks = {str(record["eval_task"]) for record in records}
+ if observed_tasks != set(PAPER_TASKS):
+ missing = sorted(set(PAPER_TASKS) - observed_tasks)
+ unexpected = sorted(observed_tasks - set(PAPER_TASKS))
+ raise DiscoveryError(
+ f"paper task discovery mismatch; missing={missing}, unexpected={unexpected}"
+ )
+ if any(record["benchmark"] != record["eval_task"] for record in records):
+ raise DiscoveryError("canonical discovery requires benchmark equal to eval_task")
+ return records
+
+
+def _write_jsonl(path: Path, records: Sequence[Mapping[str, Any]], *, overwrite: bool) -> None:
+ if path.exists() and not overwrite:
+ raise FileExistsError(f"refusing to overwrite source manifest: {path}")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{path.name}.",
+ suffix=".tmp",
+ dir=str(path.parent),
+ )
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
+ for record in records:
+ handle.write(
+ json.dumps(
+ record,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ allow_nan=False,
+ )
+ )
+ handle.write("\n")
+ os.replace(temporary_name, path)
+ except Exception:
+ try:
+ os.unlink(temporary_name)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+def create_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--data-root", required=True, help="Root containing licensed sources.")
+ parser.add_argument(
+ "--runtime-root",
+ required=True,
+ help="Compatible runtime checkout containing generated paper annotations.",
+ )
+ parser.add_argument(
+ "--output",
+ required=True,
+ help="Private JSONL source manifest outside the OraRL release tree.",
+ )
+ parser.add_argument(
+ "--confirm-redistribution-authorized",
+ action="store_true",
+ help="Record explicit authorization for every discovered source.",
+ )
+ parser.add_argument("--overwrite", action="store_true")
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ namespace = create_parser().parse_args(argv)
+ output = Path(namespace.output).expanduser().resolve(strict=False)
+ release_tree = Path(namespace.runtime_root).expanduser().resolve() / "OraRL"
+ if _is_within(output, release_tree):
+ raise SystemExit("ERROR: --output must be private and outside the OraRL release tree")
+ try:
+ records = discover_eval_sources(
+ namespace.data_root,
+ namespace.runtime_root,
+ redistribution_authorized=namespace.confirm_redistribution_authorized,
+ )
+ _write_jsonl(output, records, overwrite=namespace.overwrite)
+ except (DiscoveryError, FileExistsError, OSError, ValueError) as error:
+ raise SystemExit(f"ERROR: {error}") from error
+ unlocked = sum(record["expected_count"] is None for record in records)
+ print(
+ json.dumps(
+ {
+ "output": str(output),
+ "records": len(records),
+ "tasks": len({record["eval_task"] for record in records}),
+ "unlocked_counts": unlocked,
+ "redistribution_authorized": bool(namespace.confirm_redistribution_authorized),
+ },
+ sort_keys=True,
+ )
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/code/scripts/evaluate.sh b/code/scripts/evaluate.sh
new file mode 100644
index 0000000000000000000000000000000000000000..704c64cd3690df227b4de0a8b6fc918aae4602ed
--- /dev/null
+++ b/code/scripts/evaluate.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+RELEASE_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+PYTHON_BIN="${PYTHON_BIN:-python3}"
+
+if ! "${PYTHON_BIN}" -c 'import sys; sys.exit(sys.version_info < (3, 10))' 2>/dev/null; then
+ echo "ERROR: OraRL evaluation requires Python 3.10 or newer." >&2
+ echo "Set PYTHON_BIN to the compatible verl environment." >&2
+ exit 2
+fi
+
+export PYTHONPATH="${RELEASE_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
+exec "${PYTHON_BIN}" -m orarl.cli.evaluate "$@"
diff --git a/code/scripts/finalize_eval_index.py b/code/scripts/finalize_eval_index.py
new file mode 100644
index 0000000000000000000000000000000000000000..7c9e83221316beb6eb305be9f6ac23ae6b8e289e
--- /dev/null
+++ b/code/scripts/finalize_eval_index.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+"""Finalize an annotation-only OraRL evaluation index for publication."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import tempfile
+from pathlib import Path
+from typing import Any
+
+from orarl.evaluation.card import render_index_card
+from orarl.evaluation.manifest import load_dataset_manifest
+
+ALLOWED_ROOT_ENTRIES = {
+ ".gitattributes",
+ "README.md",
+ "annotations",
+ "datasets.jsonl",
+}
+
+
+def _atomic_write(path: Path, content: str) -> None:
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{path.name}.",
+ suffix=".tmp",
+ dir=str(path.parent),
+ )
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
+ stream.write(content)
+ os.replace(temporary_name, path)
+ except Exception:
+ try:
+ os.unlink(temporary_name)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+def _set_segmentation_reader(record: dict[str, Any], reader: str) -> None:
+ if record.get("task") != "segmentation":
+ return
+ legacy = dict(record.get("legacy_environment", {}))
+ legacy["SEGMENTATION_VIDEO_READER"] = reader
+ setting = str(legacy.get("SEGMENTATION_SETTING", ""))
+ marker = f"reader{reader}"
+ if setting and marker not in setting:
+ new_marker = setting.rfind("-new")
+ setting = (
+ f"{setting[:new_marker]}-{marker}{setting[new_marker:]}"
+ if new_marker >= 0
+ else f"{setting}-{marker}"
+ )
+ legacy["SEGMENTATION_SETTING"] = setting
+ record["legacy_environment"] = legacy
+ preprocessing = dict(record.get("preprocessing", {}))
+ preprocessing["video_reader"] = reader
+ record["preprocessing"] = preprocessing
+
+
+def _annotation_assets(root: Path, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ assets: list[dict[str, Any]] = []
+ for record in records:
+ relative = str(record["annotation_path"])
+ annotation = root / relative
+ if annotation.is_symlink() or not annotation.is_file():
+ raise FileNotFoundError(f"annotation is missing: {annotation}")
+ with annotation.open("r", encoding="utf-8") as stream:
+ row_count = sum(1 for line in stream if line.strip())
+ expected = int(record["expected_count"])
+ if row_count != expected:
+ raise ValueError(
+ f"{relative}: expected {expected} rows, found {row_count}"
+ )
+ assets.append(
+ {
+ "benchmark": str(record["benchmark"]),
+ "bytes": annotation.stat().st_size,
+ "kind": "annotations",
+ "path": relative,
+ }
+ )
+ return assets
+
+
+def finalize_index(
+ root_path: str | os.PathLike[str],
+ *,
+ repo_id: str,
+ segmentation_video_reader: str,
+) -> dict[str, int]:
+ root = Path(root_path).expanduser().resolve()
+ unexpected = sorted(
+ path.name
+ for path in root.iterdir()
+ if path.name not in ALLOWED_ROOT_ENTRIES
+ )
+ if unexpected:
+ raise ValueError(
+ "metadata-only index contains unexpected root entries: "
+ + ", ".join(unexpected)
+ )
+
+ records = [dict(record) for record in load_dataset_manifest(root)]
+ for record in records:
+ _set_segmentation_reader(record, segmentation_video_reader)
+ manifest = "".join(
+ json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ + "\n"
+ for record in records
+ )
+ _atomic_write(root / "datasets.jsonl", manifest)
+
+ validated = [dict(record) for record in load_dataset_manifest(root)]
+ assets = _annotation_assets(root, validated)
+ _atomic_write(
+ root / "README.md",
+ render_index_card(validated, assets, repo_id=repo_id),
+ )
+ return {
+ "annotations": len(assets),
+ "bytes": sum(int(asset["bytes"]) for asset in assets),
+ "rows": sum(int(record["expected_count"]) for record in validated),
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--root", required=True)
+ parser.add_argument("--repo-id", default="OraRL/OraRL-Data")
+ parser.add_argument("--segmentation-video-reader", default="decord")
+ args = parser.parse_args()
+ print(
+ json.dumps(
+ finalize_index(
+ args.root,
+ repo_id=args.repo_id,
+ segmentation_video_reader=args.segmentation_video_reader,
+ ),
+ sort_keys=True,
+ )
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/scripts/install_conda_runtime_hook.sh b/code/scripts/install_conda_runtime_hook.sh
new file mode 100644
index 0000000000000000000000000000000000000000..fc00a6cf9bec4d7ea0b3204f68b8619880b5f3d0
--- /dev/null
+++ b/code/scripts/install_conda_runtime_hook.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ -z "${CONDA_PREFIX:-}" || ! -x "${CONDA_PREFIX}/bin/python" ]]; then
+ echo "ERROR: activate the target Conda environment before installing the hook." >&2
+ exit 1
+fi
+
+HOOK_DIR="${CONDA_PREFIX}/etc/conda"
+mkdir -p "${HOOK_DIR}/activate.d" "${HOOK_DIR}/deactivate.d"
+
+cat > "${HOOK_DIR}/activate.d/orarl-runtime.sh" <<'EOF'
+export _ORARL_LD_LIBRARY_PATH_WAS_SET="${LD_LIBRARY_PATH+x}"
+export _ORARL_SAVED_LD_LIBRARY_PATH="${LD_LIBRARY_PATH-}"
+
+_ORARL_NVIDIA_LIBS="$("${CONDA_PREFIX}/bin/python" -c '
+from pathlib import Path
+import sysconfig
+
+root = Path(sysconfig.get_path("purelib")) / "nvidia"
+print(":".join(str(path) for path in sorted(root.glob("*/lib")) if path.is_dir()))
+')"
+_ORARL_RUNTIME_LIBS="${_ORARL_NVIDIA_LIBS}"
+if [[ -d "${CONDA_PREFIX}/lib" ]]; then
+ _ORARL_RUNTIME_LIBS="${_ORARL_RUNTIME_LIBS:+${_ORARL_RUNTIME_LIBS}:}${CONDA_PREFIX}/lib"
+fi
+export LD_LIBRARY_PATH="${_ORARL_RUNTIME_LIBS}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
+unset _ORARL_NVIDIA_LIBS _ORARL_RUNTIME_LIBS
+EOF
+
+cat > "${HOOK_DIR}/deactivate.d/orarl-runtime.sh" <<'EOF'
+if [[ "${_ORARL_LD_LIBRARY_PATH_WAS_SET:-}" == "x" ]]; then
+ export LD_LIBRARY_PATH="${_ORARL_SAVED_LD_LIBRARY_PATH-}"
+else
+ unset LD_LIBRARY_PATH
+fi
+unset _ORARL_LD_LIBRARY_PATH_WAS_SET _ORARL_SAVED_LD_LIBRARY_PATH
+EOF
+
+chmod 0644 \
+ "${HOOK_DIR}/activate.d/orarl-runtime.sh" \
+ "${HOOK_DIR}/deactivate.d/orarl-runtime.sh"
+
+echo "Installed OraRL Conda runtime hook in ${CONDA_PREFIX}."
+echo "Reactivate the environment to apply it."
diff --git a/code/scripts/launch_multinode.sh b/code/scripts/launch_multinode.sh
new file mode 100644
index 0000000000000000000000000000000000000000..420447e84b03fb03032df3b956c6a9af0df255ac
--- /dev/null
+++ b/code/scripts/launch_multinode.sh
@@ -0,0 +1,305 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+RELEASE_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+
+usage() {
+ cat <<'EOF'
+Usage:
+ launch_multinode.sh [launcher options] --
+
+Launcher options:
+ --hosts LIST Comma-separated SSH hosts. May also use HOSTS.
+ --hostfile FILE One SSH host per line. May also use HOSTFILE.
+ --gpus-per-node N GPUs per host (default: 8).
+ --ray-port N Ray head port (default: 6379).
+ --strict-host-key-checking MODE
+ yes (default), accept-new, ask, or no.
+ --run Start Ray and training. Default is a safe dry run.
+ --dry-run Print the launch plan without connecting.
+ --help Show this message.
+
+The arguments after -- must include --config, --model, --train-data,
+--val-data, and --output. The launcher supplies topology and --run.
+EOF
+}
+
+HOSTS_VALUE="${HOSTS:-}"
+HOSTFILE_VALUE="${HOSTFILE:-}"
+GPUS_PER_NODE="${GPUS_PER_NODE:-8}"
+RAY_PORT="${RAY_PORT:-6379}"
+STRICT_MODE="${STRICT_HOST_KEY_CHECKING:-yes}"
+EXECUTE=0
+
+while (($# > 0)); do
+ case "$1" in
+ --hosts)
+ [[ $# -ge 2 ]] || { echo "ERROR: --hosts requires a value" >&2; exit 2; }
+ HOSTS_VALUE="$2"
+ shift 2
+ ;;
+ --hostfile)
+ [[ $# -ge 2 ]] || { echo "ERROR: --hostfile requires a value" >&2; exit 2; }
+ HOSTFILE_VALUE="$2"
+ shift 2
+ ;;
+ --gpus-per-node)
+ [[ $# -ge 2 ]] || { echo "ERROR: --gpus-per-node requires a value" >&2; exit 2; }
+ GPUS_PER_NODE="$2"
+ shift 2
+ ;;
+ --ray-port)
+ [[ $# -ge 2 ]] || { echo "ERROR: --ray-port requires a value" >&2; exit 2; }
+ RAY_PORT="$2"
+ shift 2
+ ;;
+ --strict-host-key-checking)
+ [[ $# -ge 2 ]] || {
+ echo "ERROR: --strict-host-key-checking requires a value" >&2
+ exit 2
+ }
+ STRICT_MODE="$2"
+ shift 2
+ ;;
+ --run)
+ EXECUTE=1
+ shift
+ ;;
+ --dry-run)
+ EXECUTE=0
+ shift
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ break
+ ;;
+ *)
+ echo "ERROR: unknown launcher option: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+TRAIN_ARGS=("$@")
+if ((${#TRAIN_ARGS[@]} == 0)); then
+ echo "ERROR: pass orarl-train options after --" >&2
+ exit 2
+fi
+if [[ -n "${HOSTS_VALUE}" && -n "${HOSTFILE_VALUE}" ]]; then
+ echo "ERROR: use only one of HOSTS/--hosts and HOSTFILE/--hostfile" >&2
+ exit 2
+fi
+if [[ ! "${GPUS_PER_NODE}" =~ ^[1-9][0-9]*$ ]]; then
+ echo "ERROR: GPUs per node must be a positive integer" >&2
+ exit 2
+fi
+if [[ ! "${RAY_PORT}" =~ ^[0-9]+$ ]] || ((RAY_PORT < 1 || RAY_PORT > 65535)); then
+ echo "ERROR: Ray port must be between 1 and 65535" >&2
+ exit 2
+fi
+case "${STRICT_MODE}" in
+ yes|accept-new|ask|no) ;;
+ *)
+ echo "ERROR: strict host key checking must be yes, accept-new, ask, or no" >&2
+ exit 2
+ ;;
+esac
+
+for argument in "${TRAIN_ARGS[@]}"; do
+ lowered="${argument,,}"
+ if [[ "${lowered}" == "--run" || "${lowered}" == "--dry-run" ]]; then
+ echo "ERROR: training execution mode is controlled by the launcher" >&2
+ exit 2
+ fi
+ if [[ "${lowered}" =~ (^|[._-])(api[_-]?key|auth[_-]?token|access[_-]?token|secret|password|credential|private[_-]?key)(=|$) ]]; then
+ echo "ERROR: credential-shaped arguments are not forwarded" >&2
+ exit 2
+ fi
+ if [[ "${argument}" =~ ://[^/@[:space:]]+:[^@/[:space:]]+@ ]]; then
+ echo "ERROR: URLs with embedded authentication are not forwarded" >&2
+ exit 2
+ fi
+done
+
+declare -a RAW_HOSTS=()
+if [[ -n "${HOSTFILE_VALUE}" ]]; then
+ [[ -f "${HOSTFILE_VALUE}" ]] || {
+ echo "ERROR: host file does not exist: ${HOSTFILE_VALUE}" >&2
+ exit 2
+ }
+ while IFS= read -r line || [[ -n "${line}" ]]; do
+ line="${line%%#*}"
+ line="${line#"${line%%[![:space:]]*}"}"
+ line="${line%"${line##*[![:space:]]}"}"
+ [[ -n "${line}" ]] && RAW_HOSTS+=("${line}")
+ done < "${HOSTFILE_VALUE}"
+elif [[ -n "${HOSTS_VALUE}" ]]; then
+ IFS=',' read -r -a RAW_HOSTS <<< "${HOSTS_VALUE}"
+else
+ echo "ERROR: set HOSTS, set HOSTFILE, or pass the matching option" >&2
+ exit 2
+fi
+
+declare -a HOST_LIST=()
+declare -A SEEN_HOSTS=()
+for host in "${RAW_HOSTS[@]}"; do
+ host="${host#"${host%%[![:space:]]*}"}"
+ host="${host%"${host##*[![:space:]]}"}"
+ if [[ ! "${host}" =~ ^([A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$ ]]; then
+ echo "ERROR: invalid SSH host entry" >&2
+ exit 2
+ fi
+ if [[ -z "${SEEN_HOSTS[${host}]:-}" ]]; then
+ HOST_LIST+=("${host}")
+ SEEN_HOSTS["${host}"]=1
+ fi
+done
+if ((${#HOST_LIST[@]} == 0)); then
+ echo "ERROR: no hosts were provided" >&2
+ exit 2
+fi
+
+PYTHON_BIN="${PYTHON_BIN:-$(command -v python || true)}"
+if [[ -z "${PYTHON_BIN}" ]]; then
+ echo "ERROR: active Python executable was not found" >&2
+ exit 2
+fi
+if ! "${PYTHON_BIN}" -c 'import sys; sys.exit(sys.version_info < (3, 10))' 2>/dev/null; then
+ echo "ERROR: OraRL multi-node launch requires Python 3.10 or newer." >&2
+ echo "Set PYTHON_BIN to the compatible verl environment." >&2
+ exit 2
+fi
+export PYTHONPATH="${RELEASE_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
+
+FORWARD_VARIABLES=(
+ PATH
+ PYTHONPATH
+ LD_LIBRARY_PATH
+ VIRTUAL_ENV
+ CONDA_PREFIX
+ CONDA_DEFAULT_ENV
+ CUDA_VISIBLE_DEVICES
+ NCCL_DEBUG
+ NCCL_SOCKET_IFNAME
+ NCCL_IB_HCA
+ NCCL_IB_DISABLE
+ NCCL_P2P_DISABLE
+ TOKENIZERS_PARALLELISM
+ HF_HOME
+ TRANSFORMERS_CACHE
+ XDG_CACHE_HOME
+ ORARL_MEDIA_ROOT
+ ORARL_PREPROCESSED_DIR
+ ORARL_DATALOADER_WORKERS
+ ORARL_VIDEO_MAX_PIXELS
+ ORARL_VIDEO_MIN_PIXELS
+ ORARL_VIDEO_MAX_FRAMES
+ ORARL_VIDEO_FPS
+ ORARL_VIDEO_TOTAL_PIXELS
+ ORARL_IMAGE_MIN_PIXELS
+ ORARL_IMAGE_MAX_PIXELS
+ ORARL_VIDEO_SOURCE_MODE
+ ORARL_INLINE_VIDEO_TENSORS
+ ORARL_MAX_PROMPT_LENGTH
+ ORARL_MAX_RESPONSE_LENGTH
+ ORARL_ROLLOUT_BATCH_SIZE
+ ORARL_VAL_BATCH_SIZE
+ ORARL_ENABLE_THINKING
+ ORARL_SEED
+ ORARL_GLOBAL_BATCH_SIZE
+ ORARL_UPDATE_MICRO_BATCH
+ ORARL_EXPERIENCE_MICRO_BATCH
+ ORARL_MAX_TOKEN_LEN_PER_GPU
+ ORARL_LEARNING_RATE
+ ORARL_ROLLOUT_BACKEND
+)
+
+SSH_OPTIONS=(
+ -o "BatchMode=yes"
+ -o "StrictHostKeyChecking=${STRICT_MODE}"
+)
+
+build_remote_command() {
+ local -a parts=("env")
+ local name
+ for name in "${FORWARD_VARIABLES[@]}"; do
+ if [[ -v "${name}" ]]; then
+ parts+=("${name}=${!name}")
+ fi
+ done
+ parts+=("$@")
+ local rendered
+ printf -v rendered '%q ' "${parts[@]}"
+ printf '%s' "${rendered% }"
+}
+
+display_command() {
+ local host="$1"
+ local label="$2"
+ shift 2
+ local rendered
+ printf -v rendered '%q ' "$@"
+ printf '[dry-run] %s on %s: %s\n' "${label}" "${host}" "${rendered% }"
+ printf ' forwards %d allowlisted environment names when set\n' \
+ "${#FORWARD_VARIABLES[@]}"
+}
+
+run_remote() {
+ local host="$1"
+ local label="$2"
+ shift 2
+ if ((EXECUTE == 0)); then
+ display_command "${host}" "${label}" "$@"
+ return
+ fi
+ printf '[run] %s on %s\n' "${label}" "${host}"
+ remote_command="$(build_remote_command "$@")"
+ ssh "${SSH_OPTIONS[@]}" "${host}" "${remote_command}"
+}
+
+HEAD_HOST="${HOST_LIST[0]}"
+HEAD_ADDRESS="${HEAD_HOST##*@}"
+RAY_ADDRESS="${HEAD_ADDRESS}:${RAY_PORT}"
+NODE_COUNT="${#HOST_LIST[@]}"
+
+run_remote "${HEAD_HOST}" "Ray head" \
+ "${PYTHON_BIN}" -m ray start \
+ --head \
+ "--port=${RAY_PORT}" \
+ "--num-gpus=${GPUS_PER_NODE}" \
+ --disable-usage-stats
+
+for ((index = 1; index < NODE_COUNT; index++)); do
+ run_remote "${HOST_LIST[index]}" "Ray worker" \
+ "${PYTHON_BIN}" -m ray start \
+ "--address=${RAY_ADDRESS}" \
+ "--num-gpus=${GPUS_PER_NODE}" \
+ --disable-usage-stats
+done
+
+TRAIN_COMMAND=(
+ "${PYTHON_BIN}"
+ -m
+ orarl.cli.train
+ "${TRAIN_ARGS[@]}"
+ --nodes
+ "${NODE_COUNT}"
+ --gpus-per-node
+ "${GPUS_PER_NODE}"
+ --run
+)
+
+if ((EXECUTE == 0)); then
+ display_command "${HEAD_HOST}" "trainer (RAY_ADDRESS set at execution)" \
+ "${TRAIN_COMMAND[@]}"
+else
+ printf '[run] trainer on %s\n' "${HEAD_HOST}"
+ remote_command="$(build_remote_command "RAY_ADDRESS=${RAY_ADDRESS}" "${TRAIN_COMMAND[@]}")"
+ ssh "${SSH_OPTIONS[@]}" "${HEAD_HOST}" "${remote_command}"
+fi
diff --git a/code/scripts/model_merger.py b/code/scripts/model_merger.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1b10230d8ac36be8bc40c845f02bc5304466116
--- /dev/null
+++ b/code/scripts/model_merger.py
@@ -0,0 +1,347 @@
+# Copyright 2024 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.
+
+import argparse
+import os
+import re
+import shutil
+from concurrent.futures import ThreadPoolExecutor
+
+import numpy as np
+import torch
+from torch.distributed._tensor import DTensor, Placement, Shard
+from transformers import (
+ AutoConfig,
+ AutoModelForCausalLM,
+ AutoModelForImageTextToText,
+ AutoModelForTokenClassification,
+ PretrainedConfig,
+ PreTrainedModel,
+)
+
+
+def merge_by_placement(tensors: list[torch.Tensor], placement: Placement):
+ if placement.is_replicate():
+ return tensors[0]
+ elif placement.is_partial():
+ raise NotImplementedError("Partial placement is not supported yet")
+ elif placement.is_shard():
+ return torch.cat(tensors, dim=placement.dim).contiguous()
+ else:
+ raise ValueError(f"Unsupported placement: {placement}")
+
+
+def upload_model_to_huggingface(local_path: str, remote_path: str):
+ # Push to hugging face
+ from huggingface_hub import HfApi
+
+ api = HfApi()
+ api.create_repo(repo_id=remote_path, private=False, exist_ok=True)
+ api.upload_folder(repo_id=remote_path, folder_path=local_path, repo_type="model")
+
+
+def _load_missing_keys_from_base(
+ base_model_path: str,
+ missing_keys: set[str],
+) -> dict[str, torch.Tensor]:
+ """Stream the base model's safetensors shards and pick out only the
+ parameters listed in ``missing_keys``. Used to backfill frozen modules
+ (e.g. ``visual.*`` when freeze_vision_tower=True) that never end up in
+ the FSDP per-rank state_dict."""
+ from safetensors.torch import load_file as _safe_load
+
+ if not missing_keys:
+ return {}
+
+ shard_paths = sorted(
+ os.path.join(base_model_path, name)
+ for name in os.listdir(base_model_path)
+ if name.endswith(".safetensors")
+ )
+ if not shard_paths:
+ raise FileNotFoundError(
+ f"--base_model_path={base_model_path} has no .safetensors files; "
+ "cannot backfill frozen weights."
+ )
+
+ found: dict[str, torch.Tensor] = {}
+ remaining = set(missing_keys)
+ for path in shard_paths:
+ if not remaining:
+ break
+ shard = _safe_load(path, device="cpu")
+ hit = remaining & shard.keys()
+ for k in hit:
+ found[k] = shard[k].to(torch.bfloat16).contiguous()
+ remaining -= hit
+ del shard
+ if remaining:
+ print(
+ f"[merger] WARNING: {len(remaining)} keys still missing after "
+ f"scanning base model. First 5: {sorted(list(remaining))[:5]}"
+ )
+ print(f"[merger] backfilled {len(found)} keys from base model.")
+ return found
+
+
+def _copy_hf_metadata_from_base(base_model_path: str, hf_path: str) -> None:
+ """Copy non-weight HF files needed to load config/tokenizer/processor.
+
+ Some thinned FSDP checkpoints keep only ``model_world_size_*`` shards and may
+ not preserve ``actor/huggingface/config.json``. The merger still needs those
+ metadata files before it can instantiate the architecture and write the
+ merged weights.
+ """
+ os.makedirs(hf_path, exist_ok=True)
+ for name in os.listdir(base_model_path):
+ # Do not copy base weights or stale shard indexes into the output dir.
+ if (
+ name.endswith((".safetensors", ".bin", ".pt"))
+ or name.startswith("model-")
+ or name == "model.safetensors.index.json"
+ ):
+ continue
+ src = os.path.join(base_model_path, name)
+ dst = os.path.join(hf_path, name)
+ if os.path.isdir(src):
+ shutil.copytree(src, dst, dirs_exist_ok=True)
+ elif os.path.isfile(src):
+ shutil.copy2(src, dst)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--local_dir", required=True, type=str, help="The path for your saved model")
+ parser.add_argument("--hf_upload_path", default=False, type=str, help="The path of the huggingface repo to upload")
+ parser.add_argument(
+ "--base_model_path",
+ default=None,
+ type=str,
+ help="Optional path to the original HF base model. If provided, any "
+ "architecture parameters missing from the FSDP shards (e.g. the "
+ "vision tower when freeze_vision_tower=True) are pulled from here. "
+ "If omitted, the merger auto-discovers it from "
+ "/huggingface/config.json's `_name_or_path`.",
+ )
+ args = parser.parse_args()
+ local_dir: str = args.local_dir
+
+ assert not local_dir.endswith("huggingface"), "The local_dir should not end with huggingface."
+
+ # copy rank zero to find the shape of (dp, fsdp)
+ rank = 0
+ world_size = 0
+ for filename in os.listdir(local_dir):
+ match = re.match(r"model_world_size_(\d+)_rank_0\.pt", filename)
+ if match:
+ world_size = match.group(1)
+ break
+
+ assert world_size, "No model file with the proper format."
+
+ rank0_weight_path = os.path.join(local_dir, f"model_world_size_{world_size}_rank_{rank}.pt")
+ state_dict = torch.load(rank0_weight_path, map_location="cpu", weights_only=False)
+ pivot_key = sorted(state_dict.keys())[0]
+ weight = state_dict[pivot_key]
+ if isinstance(weight, DTensor):
+ # get sharding info
+ device_mesh = weight.device_mesh
+ mesh = device_mesh.mesh
+ mesh_dim_names = device_mesh.mesh_dim_names
+ else:
+ # for non-DTensor
+ mesh = np.array([int(world_size)], dtype=np.int64)
+ mesh_dim_names = ("fsdp",)
+
+ print(f"Got device mesh {mesh}, mesh_dim_names {mesh_dim_names}")
+
+ assert mesh_dim_names in (("fsdp",), ("ddp", "fsdp")), f"Unsupported mesh_dim_names {mesh_dim_names}."
+
+ if "tp" in mesh_dim_names:
+ # fsdp * tp
+ total_shards = mesh.shape[-1] * mesh.shape[-2]
+ mesh_shape = (mesh.shape[-2], mesh.shape[-1])
+ else:
+ # fsdp
+ total_shards = mesh.shape[-1]
+ mesh_shape = (mesh.shape[-1],)
+
+ print(f"Processing {total_shards} model shards in total.")
+ model_state_dict_lst = []
+ model_state_dict_lst.append(state_dict)
+ model_state_dict_lst.extend([""] * (total_shards - 1))
+
+ def process_one_shard(rank, model_state_dict_lst):
+ model_path = os.path.join(local_dir, f"model_world_size_{world_size}_rank_{rank}.pt")
+ state_dict = torch.load(model_path, map_location="cpu", weights_only=False)
+ model_state_dict_lst[rank] = state_dict
+ return state_dict
+
+ with ThreadPoolExecutor(max_workers=min(32, os.cpu_count())) as executor:
+ for rank in range(1, total_shards):
+ executor.submit(process_one_shard, rank, model_state_dict_lst)
+
+ state_dict: dict[str, list[torch.Tensor]] = {}
+ param_placements: dict[str, list[Placement]] = {}
+ keys = set(model_state_dict_lst[0].keys())
+ for key in keys:
+ state_dict[key] = []
+ for model_state_dict in model_state_dict_lst:
+ try:
+ tensor = model_state_dict.pop(key)
+ except Exception:
+ print(f"Cannot find key {key} in rank {rank}.")
+
+ if isinstance(tensor, DTensor):
+ state_dict[key].append(tensor._local_tensor.bfloat16())
+ placements = tuple(tensor.placements)
+ # replicated placement at ddp dimension can be discarded
+ if mesh_dim_names[0] == "ddp":
+ placements = placements[1:]
+
+ if key not in param_placements:
+ param_placements[key] = placements
+ else:
+ assert param_placements[key] == placements
+ else:
+ state_dict[key].append(tensor.bfloat16())
+
+ del model_state_dict_lst
+
+ for key in sorted(state_dict):
+ if not isinstance(state_dict[key], list):
+ print(f"No need to merge key {key}")
+ continue
+
+ if key in param_placements:
+ # merge shards
+ placements: tuple[Shard] = param_placements[key]
+ if len(mesh_shape) == 1:
+ # 1-D list, FSDP without TP
+ assert len(placements) == 1
+ shards = state_dict[key]
+ state_dict[key] = merge_by_placement(shards, placements[0])
+ else:
+ # 2-D list, FSDP + TP
+ raise NotImplementedError("FSDP + TP is not supported yet.")
+ else:
+ state_dict[key] = torch.cat(state_dict[key], dim=0)
+
+ print("Merge completed.")
+ hf_path = os.path.join(local_dir, "huggingface")
+ if not os.path.isfile(os.path.join(hf_path, "config.json")):
+ if args.base_model_path and os.path.isdir(args.base_model_path):
+ print(
+ f"[merger] {hf_path}/config.json missing; copying HF metadata "
+ f"from base model {args.base_model_path}"
+ )
+ _copy_hf_metadata_from_base(args.base_model_path, hf_path)
+ else:
+ raise FileNotFoundError(
+ f"{hf_path}/config.json is missing. Pass "
+ f"--base_model_path /path/to/base/hf/model so the merger can "
+ f"copy config/tokenizer/processor metadata before loading."
+ )
+ config: PretrainedConfig = AutoConfig.from_pretrained(hf_path)
+ architectures: list[str] = getattr(config, "architectures", ["Unknown"])
+
+ if "ForTokenClassification" in architectures[0]:
+ AutoClass = AutoModelForTokenClassification
+ elif "ForConditionalGeneration" in architectures[0]:
+ AutoClass = AutoModelForImageTextToText
+ elif "ForCausalLM" in architectures[0]:
+ AutoClass = AutoModelForCausalLM
+ else:
+ raise NotImplementedError(f"Unknown architecture {architectures}.")
+
+ # Backfill from the base model any architecture keys that didn't make it
+ # into the FSDP shards (typically the frozen vision tower when
+ # freeze_vision_tower=True). We compute the architecture's expected key
+ # set on a meta model (free), then pull only the diff from the base
+ # safetensors. vLLM rejects checkpoints with uninitialized weights, so
+ # this must run before save.
+ with torch.device("meta"):
+ meta_model: PreTrainedModel = AutoClass.from_config(config, torch_dtype=torch.bfloat16)
+ arch_keys = set(meta_model.state_dict().keys())
+ del meta_model
+ missing_keys = arch_keys - set(state_dict.keys())
+ if missing_keys:
+ base_model_path = args.base_model_path
+ if base_model_path is None:
+ base_model_path = getattr(config, "_name_or_path", None)
+ if not base_model_path or not os.path.isdir(base_model_path):
+ raise RuntimeError(
+ f"[merger] {len(missing_keys)} architecture keys are missing from "
+ f"the FSDP shards (e.g. {sorted(missing_keys)[:3]}). "
+ f"Pass --base_model_path /path/to/base/hf/model to backfill "
+ f"them (or set _name_or_path in config.json to a valid base "
+ f"model directory)."
+ )
+ print(
+ f"[merger] {len(missing_keys)} keys missing from FSDP shards; "
+ f"backfilling from base model at {base_model_path}"
+ )
+ backfill = _load_missing_keys_from_base(base_model_path, missing_keys)
+ state_dict.update(backfill)
+ del backfill
+
+ # Sanity check: every key has a real (non-empty) tensor.
+ extra_keys = set(state_dict.keys()) - arch_keys
+ if extra_keys:
+ print(
+ f"[merger] dropping {len(extra_keys)} keys not declared by the "
+ f"architecture (likely optimizer/training-only state). "
+ f"Examples: {sorted(extra_keys)[:3]}"
+ )
+ for k in extra_keys:
+ state_dict.pop(k, None)
+
+ # Write the safetensors file ourselves to avoid HF `save_pretrained`'s
+ # implicit key remapping. On nested VL architectures (Qwen3.5-VL etc.)
+ # save_pretrained(state_dict=…) silently renames `model.visual.*` to
+ # `model.language_model.visual.*`, producing a file that vLLM refuses to
+ # load. Writing the merged dict directly preserves the exact same key
+ # layout as the base HF model, which vLLM already knows how to map.
+ from safetensors.torch import save_file as _safe_save
+
+ # save_pretrained would also have written config.json + generation_config
+ # + processor + tokenizer, but those files are already present in
+ # hf_path (verl copies them at training-init time), so we only need to
+ # (re)write the weight file and refresh the safetensors index if any.
+ hf_weights_path = os.path.join(hf_path, "model.safetensors")
+ print(f"Writing merged weights to {hf_weights_path} ({len(state_dict)} keys)")
+ # safetensors requires contiguous tensors.
+ save_dict: dict[str, torch.Tensor] = {}
+ for k, v in state_dict.items():
+ t = v if isinstance(v, torch.Tensor) else torch.as_tensor(v)
+ if t.dtype != torch.bfloat16:
+ t = t.to(torch.bfloat16)
+ save_dict[k] = t.contiguous()
+ _safe_save(save_dict, hf_weights_path, metadata={"format": "pt"})
+ del state_dict, save_dict
+
+ # Drop any stale sharded index that would mislead the HF loader; the
+ # weights now live in a single model.safetensors.
+ index_path = os.path.join(hf_path, "model.safetensors.index.json")
+ if os.path.exists(index_path):
+ print(f"Removing stale {index_path}")
+ os.remove(index_path)
+ for stale_shard in os.listdir(hf_path):
+ if stale_shard.startswith("model-") and stale_shard.endswith(".safetensors"):
+ os.remove(os.path.join(hf_path, stale_shard))
+
+ # We no longer call save_pretrained, so generation_config.json /
+ # tokenizer / processor configs are not touched and don't need a backup.
+ if args.hf_upload_path:
+ upload_model_to_huggingface(hf_path, args.hf_upload_path)
diff --git a/code/scripts/organize_evaluation_outputs.py b/code/scripts/organize_evaluation_outputs.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a0178ac45aeb7e465f2dc347152abda0f87467b
--- /dev/null
+++ b/code/scripts/organize_evaluation_outputs.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""Move legacy evaluation outputs into paper-level task families."""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+from typing import Iterable
+
+TASK_FAMILIES = {
+ "video_qa": (
+ "videomme",
+ "videommev2",
+ "videommmu",
+ "mmvu",
+ "mvbench",
+ "videoholmes",
+ "longvideobench",
+ "lvbench",
+ "mlvu",
+ ),
+ "spatial_intelligence": ("vsi", "mmsi", "mindcube", "revsi"),
+ "spatial_temporal_grounding": ("stvg",),
+}
+
+
+def _exists(path: Path) -> bool:
+ return path.exists() or path.is_symlink()
+
+
+def planned_moves(root: Path) -> list[tuple[Path, Path]]:
+ moves = []
+ for family, tasks in TASK_FAMILIES.items():
+ for task in tasks:
+ source = root / task
+ if source.is_dir():
+ moves.append((source, root / family / task))
+ return moves
+
+
+def _preflight_merge(source: Path, destination: Path) -> None:
+ for child in source.iterdir():
+ target = destination / child.name
+ if not _exists(target):
+ continue
+ if child.is_dir() and not child.is_symlink():
+ if not target.is_dir() or target.is_symlink():
+ raise FileExistsError(f"cannot merge directory into {target}")
+ _preflight_merge(child, target)
+ continue
+ raise FileExistsError(f"refusing to overwrite existing output: {target}")
+
+
+def _merge(source: Path, destination: Path) -> None:
+ destination.mkdir(parents=True, exist_ok=True)
+ for child in source.iterdir():
+ target = destination / child.name
+ if child.is_dir() and not child.is_symlink() and target.is_dir():
+ _merge(child, target)
+ else:
+ child.rename(target)
+ source.rmdir()
+
+
+def organize(root: Path, moves: Iterable[tuple[Path, Path]]) -> None:
+ moves = list(moves)
+ for source, destination in moves:
+ if _exists(destination):
+ _preflight_merge(source, destination)
+
+ for source, destination in moves:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if _exists(destination):
+ _merge(source, destination)
+ else:
+ source.rename(destination)
+
+
+def create_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "root",
+ type=Path,
+ help="One model's output root, for example outputs/Video-ORA-9B.",
+ )
+ parser.add_argument(
+ "--apply",
+ action="store_true",
+ help="Perform the moves. Without this flag, only print the plan.",
+ )
+ return parser
+
+
+def main() -> int:
+ args = create_parser().parse_args()
+ root = args.root.expanduser().resolve()
+ if not root.is_dir():
+ raise SystemExit(f"output root is not a directory: {root}")
+
+ moves = planned_moves(root)
+ if not moves:
+ print(f"Already organized: {root}")
+ return 0
+
+ action = "MOVE" if args.apply else "PLAN"
+ for source, destination in moves:
+ print(f"{action} {source.relative_to(root)} -> {destination.relative_to(root)}")
+
+ if args.apply:
+ organize(root, moves)
+ print(f"Organized {len(moves)} task director{'y' if len(moves) == 1 else 'ies'}.")
+ else:
+ print("Dry run only; pass --apply to move these directories.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/code/scripts/prepare_data.sh b/code/scripts/prepare_data.sh
new file mode 100644
index 0000000000000000000000000000000000000000..15db2f9ec50bd670e22c5bfe64806a3215156cae
--- /dev/null
+++ b/code/scripts/prepare_data.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ORARL_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+PYTHON_BIN="${PYTHON_BIN:-python3}"
+
+if [[ "$#" -eq 0 ]]; then
+ echo "Usage: $0 --config SOURCES.yaml --output TRAIN.jsonl [options]" >&2
+ echo "No data is downloaded; all source and media paths must be supplied locally." >&2
+ exit 2
+fi
+
+if ! "${PYTHON_BIN}" -c 'import sys; sys.exit(sys.version_info < (3, 10))'; then
+ echo "OraRL data preparation requires Python 3.10 or newer." >&2
+ echo "Set PYTHON_BIN to a compatible interpreter." >&2
+ exit 2
+fi
+
+export PYTHONPATH="${ORARL_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
+exec "${PYTHON_BIN}" -m orarl.data.build "$@"
diff --git a/code/scripts/smoke_evaluation.sh b/code/scripts/smoke_evaluation.sh
new file mode 100644
index 0000000000000000000000000000000000000000..819f7ada81447280265cbbb9ec506121d84a49d3
--- /dev/null
+++ b/code/scripts/smoke_evaluation.sh
@@ -0,0 +1,382 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ORARL_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+EVALUATOR="${ORARL_ROOT}/eval/task/eval.sh"
+
+PYTHON_BIN="${PYTHON_BIN:-python}"
+MODEL=""
+DATASET=""
+SAM2_CKPT=""
+SAM2_CFG=""
+POSTPROCESSOR=""
+GPUS="${GPUS:-0}"
+TP_SIZE="${TP_SIZE:-1}"
+BATCH_SIZE="${BATCH_SIZE:-1}"
+VIDEO_SAMPLES="${VIDEO_SAMPLES:-8}"
+SEGMENTATION_SAMPLES="${SEGMENTATION_SAMPLES:-2}"
+SAM2_WORKERS_PER_GPU="${SAM2_WORKERS_PER_GPU:-1}"
+RESULTS_ROOT=""
+RUN_VIDEO=1
+RUN_SEGMENTATION=1
+EXECUTE=1
+
+usage() {
+ cat <<'EOF'
+Usage:
+ bash scripts/smoke_evaluation.sh \
+ --model PATH \
+ --dataset PATH_OR_HF_REPO \
+ --sam2-ckpt PATH \
+ --sam2-cfg PATH \
+ --postprocessor PATH \
+ [options]
+
+Runs two bounded checks using this checkout's eval/task/eval.sh:
+ 1. VideoMME inference (8 samples by default)
+ 2. MeViS inference + SAM2 mask metrics (2 samples by default)
+
+Required:
+ --model PATH Exported HF model or veRL actor checkpoint.
+ --dataset PATH_OR_REPO Canonical OraRL evaluation dataset.
+
+Required unless --skip-segmentation:
+ --sam2-ckpt PATH SAM2 checkpoint.
+ --sam2-cfg PATH SAM2 Hydra YAML file.
+ --postprocessor PATH Official OneThinker seg_post_sam2.py.
+
+Options:
+ --gpus LIST GPU IDs (default: 0).
+ --tp-size N Tensor parallel size (default: 1).
+ --batch-size N Inference batch size (default: 1).
+ --video-samples N VideoMME sample count (default: 8).
+ --segmentation-samples N MeViS sample count (default: 2).
+ --sam2-workers-per-gpu N SAM2 workers per GPU (default: 1).
+ --results-root PATH Smoke log and aggregate-summary directory.
+ --skip-video Do not run VideoMME.
+ --skip-segmentation Do not run MeViS + SAM2.
+ --dry-run Resolve and print commands without GPU inference.
+ -h, --help Show this message.
+
+Environment:
+ PYTHON_BIN Python from the installed OraRL environment.
+EOF
+}
+
+require_value() {
+ local option="$1"
+ local value="${2:-}"
+ if [[ -z "${value}" ]]; then
+ echo "ERROR: ${option} requires a value." >&2
+ exit 2
+ fi
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --model)
+ require_value "$1" "${2:-}"
+ MODEL="$2"
+ shift 2
+ ;;
+ --dataset)
+ require_value "$1" "${2:-}"
+ DATASET="$2"
+ shift 2
+ ;;
+ --sam2-ckpt)
+ require_value "$1" "${2:-}"
+ SAM2_CKPT="$2"
+ shift 2
+ ;;
+ --sam2-cfg)
+ require_value "$1" "${2:-}"
+ SAM2_CFG="$2"
+ shift 2
+ ;;
+ --postprocessor)
+ require_value "$1" "${2:-}"
+ POSTPROCESSOR="$2"
+ shift 2
+ ;;
+ --gpus)
+ require_value "$1" "${2:-}"
+ GPUS="$2"
+ shift 2
+ ;;
+ --tp-size)
+ require_value "$1" "${2:-}"
+ TP_SIZE="$2"
+ shift 2
+ ;;
+ --batch-size)
+ require_value "$1" "${2:-}"
+ BATCH_SIZE="$2"
+ shift 2
+ ;;
+ --video-samples)
+ require_value "$1" "${2:-}"
+ VIDEO_SAMPLES="$2"
+ shift 2
+ ;;
+ --segmentation-samples)
+ require_value "$1" "${2:-}"
+ SEGMENTATION_SAMPLES="$2"
+ shift 2
+ ;;
+ --sam2-workers-per-gpu)
+ require_value "$1" "${2:-}"
+ SAM2_WORKERS_PER_GPU="$2"
+ shift 2
+ ;;
+ --results-root)
+ require_value "$1" "${2:-}"
+ RESULTS_ROOT="$2"
+ shift 2
+ ;;
+ --skip-video)
+ RUN_VIDEO=0
+ shift
+ ;;
+ --skip-segmentation)
+ RUN_SEGMENTATION=0
+ shift
+ ;;
+ --dry-run)
+ EXECUTE=0
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "ERROR: unknown option: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ -z "${MODEL}" || -z "${DATASET}" ]]; then
+ echo "ERROR: --model and --dataset are required." >&2
+ usage >&2
+ exit 2
+fi
+if [[ ! -d "${MODEL}" ]]; then
+ echo "ERROR: model directory does not exist: ${MODEL}" >&2
+ exit 2
+fi
+if [[ ! -f "${EVALUATOR}" ]]; then
+ echo "ERROR: in-repo evaluator is missing: ${EVALUATOR}" >&2
+ exit 2
+fi
+if [[ -d "${DATASET}" ]]; then
+ for manifest in datasets.jsonl assets.jsonl; do
+ if [[ ! -f "${DATASET}/${manifest}" ]]; then
+ echo "ERROR: canonical dataset is missing ${manifest}: ${DATASET}" >&2
+ exit 2
+ fi
+ done
+fi
+if [[ "${RUN_SEGMENTATION}" -eq 1 ]]; then
+ for value in "${SAM2_CKPT}" "${SAM2_CFG}" "${POSTPROCESSOR}"; do
+ if [[ -z "${value}" || ! -f "${value}" ]]; then
+ echo "ERROR: segmentation input is missing or not a file: ${value:-}" >&2
+ exit 2
+ fi
+ done
+fi
+if [[ "${RUN_VIDEO}" -eq 0 && "${RUN_SEGMENTATION}" -eq 0 ]]; then
+ echo "ERROR: both smoke tests were disabled." >&2
+ exit 2
+fi
+
+MODEL="$(readlink -f "${MODEL}")"
+if [[ -d "${DATASET}" ]]; then
+ DATASET="$(readlink -f "${DATASET}")"
+fi
+if [[ "${RUN_SEGMENTATION}" -eq 1 ]]; then
+ SAM2_CKPT="$(readlink -f "${SAM2_CKPT}")"
+ SAM2_CFG="$(readlink -f "${SAM2_CFG}")"
+ POSTPROCESSOR="$(readlink -f "${POSTPROCESSOR}")"
+fi
+
+if ! "${PYTHON_BIN}" -c 'import sys; raise SystemExit(sys.version_info < (3, 10))'; then
+ echo "ERROR: PYTHON_BIN must be Python 3.10 or newer: ${PYTHON_BIN}" >&2
+ exit 2
+fi
+
+for value in \
+ "${TP_SIZE}" \
+ "${BATCH_SIZE}" \
+ "${VIDEO_SAMPLES}" \
+ "${SEGMENTATION_SAMPLES}" \
+ "${SAM2_WORKERS_PER_GPU}"; do
+ if ! [[ "${value}" =~ ^[1-9][0-9]*$ ]]; then
+ echo "ERROR: numeric settings must be positive integers; got ${value}" >&2
+ exit 2
+ fi
+done
+
+IFS=',' read -r -a GPU_IDS <<< "${GPUS}"
+if [[ "${#GPU_IDS[@]}" -eq 0 ]]; then
+ echo "ERROR: --gpus must contain at least one GPU ID." >&2
+ exit 2
+fi
+
+RUN_STAMP="$(date +%Y%m%d_%H%M%S)"
+RESULTS_ROOT="${RESULTS_ROOT:-${ORARL_ROOT}/outputs/smoke-evaluation/${RUN_STAMP}}"
+mkdir -p "${RESULTS_ROOT}"
+LOG="${RESULTS_ROOT}/smoke.log"
+exec > >(tee -a "${LOG}") 2>&1
+
+export PYTHONPATH="${ORARL_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
+export FORCE_QWENVL_VIDEO_READER=decord
+export DECORD_EOF_RETRY_MAX="${DECORD_EOF_RETRY_MAX:-20480}"
+
+echo "OraRL root: ${ORARL_ROOT}"
+echo "Evaluator: ${EVALUATOR}"
+echo "Python: $("${PYTHON_BIN}" -c 'import sys; print(sys.executable)')"
+echo "Model: ${MODEL}"
+echo "Dataset: ${DATASET}"
+echo "GPUs: ${GPUS}"
+echo "Results: ${RESULTS_ROOT}"
+echo "Execute: ${EXECUTE}"
+
+if [[ "${EXECUTE}" -eq 1 ]]; then
+ if ! command -v nvidia-smi >/dev/null 2>&1; then
+ echo "ERROR: nvidia-smi is unavailable; run this script on a GPU node." >&2
+ exit 1
+ fi
+ nvidia-smi --query-gpu=index,name,memory.used,memory.total --format=csv,noheader
+fi
+
+"${PYTHON_BIN}" - <<'PY'
+import importlib
+
+required = ("numpy", "torch", "transformers", "vllm", "qwen_vl_utils", "decord")
+for name in required:
+ module = importlib.import_module(name)
+ print(f"dependency OK: {name} {getattr(module, '__version__', '')}")
+PY
+
+if [[ "${RUN_SEGMENTATION}" -eq 1 ]]; then
+ PYTHONPATH="${ORARL_ROOT}/eval/task:${PYTHONPATH}" "${PYTHON_BIN}" - <<'PY'
+import os
+
+os.environ["FORCE_QWENVL_VIDEO_READER"] = "decord"
+import qwenvl_decord_patch
+from qwen_vl_utils import vision_process
+
+if vision_process.fetch_video is not qwenvl_decord_patch._decord_fetch_video_new_api:
+ raise RuntimeError("the in-repo decord patch did not replace fetch_video")
+print(f"decord patch OK: {qwenvl_decord_patch.__file__}")
+PY
+ "${PYTHON_BIN}" -c 'import sam2; print(f"SAM2 package OK: {sam2.__file__}")'
+fi
+
+RUN_ARGUMENT=()
+if [[ "${EXECUTE}" -eq 1 ]]; then
+ RUN_ARGUMENT=(--run)
+fi
+
+COMMON=(
+ --model "${MODEL}"
+ --dataset "${DATASET}"
+ --evaluator "${EVALUATOR}"
+ --gpus "${GPUS}"
+ --tp-size "${TP_SIZE}"
+ --batch-size "${BATCH_SIZE}"
+)
+
+assert_aggregate_summary() {
+ local summary="$1"
+ local task="$2"
+ "${PYTHON_BIN}" - "${summary}" "${task}" <<'PY'
+import json
+import sys
+from pathlib import Path
+
+path = Path(sys.argv[1])
+task = sys.argv[2]
+payload = json.loads(path.read_text(encoding="utf-8"))
+if payload.get("evaluator_returncode") != 0:
+ raise SystemExit(f"{task} evaluator failed: {path}")
+if task not in payload.get("completed_tasks", []):
+ raise SystemExit(
+ f"{task} produced no discoverable task summary: {path}; "
+ f"missing={payload.get('missing_tasks', [])}"
+ )
+print(f"aggregate summary OK: {task} -> {path}")
+PY
+}
+
+if [[ "${RUN_VIDEO}" -eq 1 ]]; then
+ echo
+ echo "=== VideoMME smoke (${VIDEO_SAMPLES} samples) ==="
+ "${PYTHON_BIN}" -m orarl.cli.evaluate \
+ "${COMMON[@]}" \
+ --tasks videomme \
+ --max-samples "${VIDEO_SAMPLES}" \
+ --summary "${RESULTS_ROOT}/videomme-summary.json" \
+ "${RUN_ARGUMENT[@]}"
+ if [[ "${EXECUTE}" -eq 1 ]]; then
+ assert_aggregate_summary "${RESULTS_ROOT}/videomme-summary.json" "videomme"
+ fi
+fi
+
+if [[ "${RUN_SEGMENTATION}" -eq 1 ]]; then
+ TASK_CONFIG="${RESULTS_ROOT}/segmentation-smoke.json"
+ "${PYTHON_BIN}" - \
+ "${TASK_CONFIG}" \
+ "${SAM2_CKPT}" \
+ "${SAM2_CFG}" \
+ "${POSTPROCESSOR}" \
+ "${#GPU_IDS[@]}" \
+ "${SAM2_WORKERS_PER_GPU}" <<'PY'
+import json
+import sys
+
+output, checkpoint, config, postprocessor, gpu_count, workers = sys.argv[1:]
+payload = {
+ "task": "segmentation",
+ "environment": {
+ "SEGMENTATION_DATASETS": "mevis",
+ "SEGMENTATION_DATA_TYPE": "video",
+ "SEGMENTATION_VIDEO_READER": "decord",
+ "SEGMENTATION_SETTING": "smoke-mevis-video-decord",
+ "SEGMENTATION_SAM2_CKPT": checkpoint,
+ "SEGMENTATION_SAM2_CFG": config,
+ "SEGMENTATION_POSTPROCESSOR_PATH": postprocessor,
+ "SEGMENTATION_SAM2_NUM_GPUS": int(gpu_count),
+ "SEGMENTATION_SAM2_WORKERS_PER_GPU": int(workers),
+ },
+}
+with open(output, "w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2)
+ handle.write("\n")
+PY
+
+ echo
+ echo "=== MeViS + SAM2 smoke (${SEGMENTATION_SAMPLES} samples) ==="
+ "${PYTHON_BIN}" -m orarl.cli.evaluate \
+ "${COMMON[@]}" \
+ --tasks segmentation \
+ --task-config "segmentation=${TASK_CONFIG}" \
+ --max-samples "${SEGMENTATION_SAMPLES}" \
+ --segmentation-run-sam2 \
+ --summary "${RESULTS_ROOT}/segmentation-summary.json" \
+ "${RUN_ARGUMENT[@]}"
+ if [[ "${EXECUTE}" -eq 1 ]]; then
+ assert_aggregate_summary "${RESULTS_ROOT}/segmentation-summary.json" "segmentation"
+ fi
+fi
+
+echo
+echo "Smoke evaluation finished."
+echo "Log: ${LOG}"
+[[ "${RUN_VIDEO}" -eq 0 ]] || echo "VideoMME summary: ${RESULTS_ROOT}/videomme-summary.json"
+[[ "${RUN_SEGMENTATION}" -eq 0 ]] \
+ || echo "Segmentation summary: ${RESULTS_ROOT}/segmentation-summary.json"
diff --git a/code/scripts/smoke_training.sh b/code/scripts/smoke_training.sh
new file mode 100644
index 0000000000000000000000000000000000000000..50fa21f070cc9003257ce82d121afb2847a46b88
--- /dev/null
+++ b/code/scripts/smoke_training.sh
@@ -0,0 +1,316 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ORARL_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+
+PYTHON_BIN="${PYTHON_BIN:-python}"
+MODEL=""
+TRAIN_DATA=""
+VAL_DATA=""
+MEDIA_ROOT=""
+MODEL_SIZE="${MODEL_SIZE:-4b}"
+NODES="${NODES:-1}"
+GPUS_PER_NODE="${GPUS_PER_NODE:-8}"
+STEPS="${STEPS:-1}"
+ROLLOUT_BATCH_SIZE="${ROLLOUT_BATCH_SIZE:-8}"
+GLOBAL_BATCH_SIZE="${GLOBAL_BATCH_SIZE:-8}"
+RESULTS_ROOT=""
+RUN_GRPO=1
+RUN_ORARL=1
+EXECUTE=1
+
+usage() {
+ cat <<'EOF'
+Usage:
+ bash scripts/smoke_training.sh \
+ --model PATH \
+ --train-data PATH \
+ --val-data PATH \
+ [options]
+
+Runs two bounded updates with the trainer bundled in this checkout:
+ 1. GRPO baseline (grpo_.yaml)
+ 2. OraRL (orarl_.yaml)
+
+Each run saves a checkpoint so resume is exercised too.
+
+Required:
+ --model PATH Local base model or checkpoint directory.
+ --train-data PATH Prepared training JSONL.
+ --val-data PATH Prepared canary JSONL.
+
+Options:
+ --size 4b|9b Released recipe scale (default: 4b).
+ --media-root PATH Media root exported as ORARL_MEDIA_ROOT.
+ --nodes N Training nodes (default: 1).
+ --gpus-per-node N GPUs per node (default: 8).
+ --steps N Updates per run (default: 1).
+ --rollout-batch-size N Prompts per rollout batch (default: 8).
+ --global-batch-size N Actor update batch size (default: 8).
+ --results-root PATH Log and checkpoint directory.
+ --skip-grpo Do not run the GRPO baseline.
+ --skip-orarl Do not run OraRL.
+ --dry-run Resolve and print commands without training.
+ -h, --help Show this message.
+
+Environment:
+ PYTHON_BIN Python from the installed OraRL environment.
+EOF
+}
+
+require_value() {
+ local option="$1"
+ local value="${2:-}"
+ if [[ -z "${value}" ]]; then
+ echo "ERROR: ${option} requires a value." >&2
+ exit 2
+ fi
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --model)
+ require_value "$1" "${2:-}"
+ MODEL="$2"
+ shift 2
+ ;;
+ --train-data)
+ require_value "$1" "${2:-}"
+ TRAIN_DATA="$2"
+ shift 2
+ ;;
+ --val-data)
+ require_value "$1" "${2:-}"
+ VAL_DATA="$2"
+ shift 2
+ ;;
+ --media-root)
+ require_value "$1" "${2:-}"
+ MEDIA_ROOT="$2"
+ shift 2
+ ;;
+ --size)
+ require_value "$1" "${2:-}"
+ MODEL_SIZE="${2,,}"
+ shift 2
+ ;;
+ --nodes)
+ require_value "$1" "${2:-}"
+ NODES="$2"
+ shift 2
+ ;;
+ --gpus-per-node)
+ require_value "$1" "${2:-}"
+ GPUS_PER_NODE="$2"
+ shift 2
+ ;;
+ --steps)
+ require_value "$1" "${2:-}"
+ STEPS="$2"
+ shift 2
+ ;;
+ --rollout-batch-size)
+ require_value "$1" "${2:-}"
+ ROLLOUT_BATCH_SIZE="$2"
+ shift 2
+ ;;
+ --global-batch-size)
+ require_value "$1" "${2:-}"
+ GLOBAL_BATCH_SIZE="$2"
+ shift 2
+ ;;
+ --results-root)
+ require_value "$1" "${2:-}"
+ RESULTS_ROOT="$2"
+ shift 2
+ ;;
+ --skip-grpo)
+ RUN_GRPO=0
+ shift
+ ;;
+ --skip-orarl)
+ RUN_ORARL=0
+ shift
+ ;;
+ --dry-run)
+ EXECUTE=0
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "ERROR: unknown option: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ -z "${MODEL}" || -z "${TRAIN_DATA}" || -z "${VAL_DATA}" ]]; then
+ echo "ERROR: --model, --train-data, and --val-data are required." >&2
+ usage >&2
+ exit 2
+fi
+if [[ ! -d "${MODEL}" ]]; then
+ echo "ERROR: model directory does not exist: ${MODEL}" >&2
+ exit 2
+fi
+for value in "${TRAIN_DATA}" "${VAL_DATA}"; do
+ if [[ ! -f "${value}" ]]; then
+ echo "ERROR: data file does not exist: ${value}" >&2
+ exit 2
+ fi
+done
+if [[ -n "${MEDIA_ROOT}" && ! -d "${MEDIA_ROOT}" ]]; then
+ echo "ERROR: media root does not exist: ${MEDIA_ROOT}" >&2
+ exit 2
+fi
+case "${MODEL_SIZE}" in
+ 4b|9b) ;;
+ *)
+ echo "ERROR: --size must be 4b or 9b; got ${MODEL_SIZE}" >&2
+ exit 2
+ ;;
+esac
+if [[ ! -d "${ORARL_ROOT}/verl" ]]; then
+ echo "ERROR: the bundled training runtime is missing: ${ORARL_ROOT}/verl" >&2
+ exit 2
+fi
+if [[ "${RUN_GRPO}" -eq 0 && "${RUN_ORARL}" -eq 0 ]]; then
+ echo "ERROR: both smoke runs were disabled." >&2
+ exit 2
+fi
+for value in \
+ "${NODES}" \
+ "${GPUS_PER_NODE}" \
+ "${STEPS}" \
+ "${ROLLOUT_BATCH_SIZE}" \
+ "${GLOBAL_BATCH_SIZE}"; do
+ if ! [[ "${value}" =~ ^[1-9][0-9]*$ ]]; then
+ echo "ERROR: numeric settings must be positive integers; got ${value}" >&2
+ exit 2
+ fi
+done
+
+MODEL="$(readlink -f "${MODEL}")"
+TRAIN_DATA="$(readlink -f "${TRAIN_DATA}")"
+VAL_DATA="$(readlink -f "${VAL_DATA}")"
+if [[ -n "${MEDIA_ROOT}" ]]; then
+ MEDIA_ROOT="$(readlink -f "${MEDIA_ROOT}")"
+ export ORARL_MEDIA_ROOT="${MEDIA_ROOT}"
+fi
+
+if ! "${PYTHON_BIN}" -c 'import sys; raise SystemExit(sys.version_info < (3, 10))'; then
+ echo "ERROR: PYTHON_BIN must be Python 3.10 or newer: ${PYTHON_BIN}" >&2
+ exit 2
+fi
+
+RUN_STAMP="$(date +%Y%m%d_%H%M%S)"
+RESULTS_ROOT="${RESULTS_ROOT:-${ORARL_ROOT}/outputs/smoke-training/${RUN_STAMP}}"
+mkdir -p "${RESULTS_ROOT}"
+LOG="${RESULTS_ROOT}/smoke.log"
+exec > >(tee -a "${LOG}") 2>&1
+
+export PYTHONPATH="${ORARL_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
+export FORCE_QWENVL_VIDEO_READER=decord
+export DECORD_EOF_RETRY_MAX="${DECORD_EOF_RETRY_MAX:-20480}"
+
+echo "OraRL root: ${ORARL_ROOT}"
+echo "Python: $("${PYTHON_BIN}" -c 'import sys; print(sys.executable)')"
+echo "Model: ${MODEL}"
+echo "Train data: ${TRAIN_DATA}"
+echo "Val data: ${VAL_DATA}"
+echo "Recipe scale: ${MODEL_SIZE}"
+echo "World size: ${NODES} x ${GPUS_PER_NODE}"
+echo "Steps per run: ${STEPS}"
+echo "Results: ${RESULTS_ROOT}"
+echo "Execute: ${EXECUTE}"
+
+if [[ "${EXECUTE}" -eq 1 ]]; then
+ if ! command -v nvidia-smi >/dev/null 2>&1; then
+ echo "ERROR: nvidia-smi is unavailable; run this script on a GPU node." >&2
+ exit 1
+ fi
+ nvidia-smi --query-gpu=index,name,memory.used,memory.total --format=csv,noheader
+
+ # The GPU stack is only needed for a real update, so --dry-run stays usable
+ # from a login node.
+ "${PYTHON_BIN}" - <<'PY'
+import importlib
+
+required = (
+ "numpy",
+ "torch",
+ "transformers",
+ "vllm",
+ "ray",
+ "tensordict",
+ "codetiming",
+ "omegaconf",
+ "verl.trainer.main",
+ "orarl.rewards",
+)
+for name in required:
+ module = importlib.import_module(name)
+ version = getattr(module, "__version__", None)
+ print(f"dependency OK: {name} {version or module.__file__}")
+PY
+fi
+
+RUN_ARGUMENT=()
+if [[ "${EXECUTE}" -eq 1 ]]; then
+ RUN_ARGUMENT=(--run)
+fi
+
+assert_checkpoint() {
+ local output="$1"
+ local method="$2"
+ "${PYTHON_BIN}" - "${output}" "${method}" <<'PY'
+import sys
+from pathlib import Path
+
+output = Path(sys.argv[1])
+method = sys.argv[2]
+steps = sorted(output.glob("global_step_*"))
+if not steps:
+ raise SystemExit(f"{method} wrote no checkpoint under {output}")
+print(f"checkpoint OK: {method} -> {steps[-1]}")
+PY
+}
+
+run_recipe() {
+ local method="$1"
+ local output="${RESULTS_ROOT}/${method}"
+ echo
+ echo "=== ${method} smoke (${STEPS} update(s)) ==="
+ "${PYTHON_BIN}" -m orarl.cli.train \
+ --config "${ORARL_ROOT}/configs/${method}_${MODEL_SIZE}.yaml" \
+ --model "${MODEL}" \
+ --train-data "${TRAIN_DATA}" \
+ --val-data "${VAL_DATA}" \
+ --output "${output}" \
+ --nodes "${NODES}" \
+ --gpus-per-node "${GPUS_PER_NODE}" \
+ --set "trainer.max_steps=${STEPS}" \
+ --set "trainer.save_freq=${STEPS}" \
+ --set "trainer.val_before_train=false" \
+ --set "trainer.experiment_name=smoke-${method}-${MODEL_SIZE}" \
+ --set "data.rollout_batch_size=${ROLLOUT_BATCH_SIZE}" \
+ --set "worker.actor.global_batch_size=${GLOBAL_BATCH_SIZE}" \
+ "${RUN_ARGUMENT[@]}"
+ if [[ "${EXECUTE}" -eq 1 ]]; then
+ assert_checkpoint "${output}" "${method}"
+ fi
+}
+
+[[ "${RUN_GRPO}" -eq 0 ]] || run_recipe grpo
+[[ "${RUN_ORARL}" -eq 0 ]] || run_recipe orarl
+
+echo
+echo "Smoke training finished."
+echo "Log: ${LOG}"
+[[ "${RUN_GRPO}" -eq 0 ]] || echo "GRPO output: ${RESULTS_ROOT}/grpo"
+[[ "${RUN_ORARL}" -eq 0 ]] || echo "OraRL output: ${RESULTS_ROOT}/orarl"
diff --git a/code/scripts/train_grpo.sh b/code/scripts/train_grpo.sh
new file mode 100644
index 0000000000000000000000000000000000000000..12bf2d7580f5cfba687aa51cbc7d429187bca11c
--- /dev/null
+++ b/code/scripts/train_grpo.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+RELEASE_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+PYTHON_BIN="${PYTHON_BIN:-python3}"
+MODEL_SIZE="${MODEL_SIZE:-4b}"
+
+if [[ "${1:-}" == "--size" ]]; then
+ [[ $# -ge 2 ]] || {
+ echo "ERROR: --size requires 4b or 9b" >&2
+ exit 2
+ }
+ MODEL_SIZE="${2,,}"
+ shift 2
+fi
+
+MODEL_SIZE="${MODEL_SIZE,,}"
+case "${MODEL_SIZE}" in
+ 4b|9b) ;;
+ *)
+ echo "ERROR: model size must be 4b or 9b" >&2
+ exit 2
+ ;;
+esac
+
+if ! "${PYTHON_BIN}" -c 'import sys; sys.exit(sys.version_info < (3, 10))' 2>/dev/null; then
+ echo "ERROR: OraRL training requires Python 3.10 or newer." >&2
+ echo "Set PYTHON_BIN to the interpreter of the installed orarl environment." >&2
+ exit 2
+fi
+
+export PYTHONPATH="${RELEASE_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
+exec "${PYTHON_BIN}" -m orarl.cli.train \
+ --config "${RELEASE_DIR}/configs/grpo_${MODEL_SIZE}.yaml" \
+ "$@"
diff --git a/code/scripts/train_orarl.sh b/code/scripts/train_orarl.sh
new file mode 100644
index 0000000000000000000000000000000000000000..4ae950e1f8793312557b3524cf91cd9aa6cc685d
--- /dev/null
+++ b/code/scripts/train_orarl.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+RELEASE_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+PYTHON_BIN="${PYTHON_BIN:-python3}"
+MODEL_SIZE="${MODEL_SIZE:-4b}"
+
+if ! "${PYTHON_BIN}" -c 'import sys; sys.exit(sys.version_info < (3, 10))' 2>/dev/null; then
+ echo "ERROR: OraRL training requires Python 3.10 or newer." >&2
+ echo "Set PYTHON_BIN to the interpreter of the installed orarl environment." >&2
+ exit 2
+fi
+
+if [[ "${1:-}" == "--size" ]]; then
+ [[ $# -ge 2 ]] || {
+ echo "ERROR: --size requires 4b or 9b" >&2
+ exit 2
+ }
+ MODEL_SIZE="${2,,}"
+ shift 2
+fi
+
+MODEL_SIZE="${MODEL_SIZE,,}"
+case "${MODEL_SIZE}" in
+ 4b|9b) ;;
+ *)
+ echo "ERROR: model size must be 4b or 9b" >&2
+ exit 2
+ ;;
+esac
+
+export PYTHONPATH="${RELEASE_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
+exec "${PYTHON_BIN}" -m orarl.cli.train \
+ --config "${RELEASE_DIR}/configs/orarl_${MODEL_SIZE}.yaml" \
+ "$@"
diff --git a/code/tests/test_algorithm_advantages.py b/code/tests/test_algorithm_advantages.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3b3961a5defd1bfadad83c8c7d29211faf5c939
--- /dev/null
+++ b/code/tests/test_algorithm_advantages.py
@@ -0,0 +1,180 @@
+# Copyright 2026 The OraRL Authors
+#
+# 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.
+
+import numpy as np
+import pytest
+import torch
+
+from orarl.algorithm import (
+ DetachedOracleConfig,
+ DirectionalGainConfig,
+ OraRLConfig,
+ compute_directional_gain,
+ compute_grpo_advantages,
+ compute_grpo_outcome_advantage,
+ compute_orarl_advantages,
+ compute_orarl_policy_advantages,
+ detached_oracle_advantage,
+ normalized_reward_gap,
+)
+
+
+@pytest.mark.parametrize(
+ "rewards",
+ [
+ [0.0, 0.5, 1.0, 0.5],
+ [-2.0, -0.5, 0.25, 3.0],
+ [0.3, 0.3, 0.3, 0.3],
+ ],
+)
+def test_vanilla_grpo_matches_reference_formula(rewards):
+ values = torch.tensor(rewards, dtype=torch.float64)
+ expected = (values - values.mean()) / (values.std(unbiased=True) + 1e-6)
+
+ actual = compute_grpo_advantages(
+ values,
+ np.asarray(["fixture"] * len(rewards), dtype=object),
+ )
+
+ assert torch.allclose(actual, expected, atol=1e-12, rtol=1e-12)
+
+
+def test_grouped_grpo_wrapper_matches_token_fixture():
+ rewards = torch.tensor([[0.0, 1.0, 9.0], [0.0, 3.0, 9.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]])
+ mask = torch.tensor(
+ [[1, 1, 0], [1, 1, 0], [1, 0, 0], [1, 0, 0]],
+ dtype=torch.bool,
+ )
+ groups = np.asarray(["a", "a", "b", "b"], dtype=object)
+
+ actual, returns = compute_grpo_outcome_advantage(
+ token_level_rewards=rewards,
+ response_mask=mask,
+ index=groups,
+ )
+
+ expected_scalars = torch.tensor(
+ [-1.0 / np.sqrt(2.0), 1.0 / np.sqrt(2.0)] * 2,
+ dtype=torch.float32,
+ )
+ expected = expected_scalars.unsqueeze(-1) * mask
+ assert torch.allclose(actual, expected, atol=1e-5)
+ assert torch.equal(actual, returns)
+
+
+def test_directional_gain_is_positive_only_and_policy_recentered():
+ rewards = torch.tensor([0.0, 0.2, 0.4, 0.6, 1.0])
+ oracle = torch.tensor([False, False, False, False, True])
+ config = DirectionalGainConfig(gamma=0.25)
+ metrics = {}
+
+ actual = compute_orarl_policy_advantages(
+ rewards,
+ ["group"] * len(rewards),
+ oracle,
+ config=config,
+ metrics_out=metrics,
+ )
+
+ policy_rewards = rewards[~oracle]
+ base = policy_rewards - policy_rewards.mean()
+ gain = compute_directional_gain(
+ rewards.std(unbiased=True),
+ policy_rewards.std(unbiased=True),
+ config=config,
+ )
+ transformed = torch.where(base > 0, base * gain, base)
+ expected = transformed - transformed.mean()
+ assert torch.allclose(actual[~oracle], expected, atol=1e-6)
+ assert actual[oracle].item() == 0.0
+ assert abs(float(actual[~oracle].mean())) < 1e-7
+ assert metrics["orarl/directional_gain_mean"] == pytest.approx(float(gain))
+
+
+def test_directional_gain_caps_degenerate_policy_spread():
+ gain = compute_directional_gain(
+ torch.tensor(0.5),
+ torch.tensor(0.0),
+ config=DirectionalGainConfig(gamma=0.25),
+ )
+
+ assert gain.item() == 4.0
+
+
+def test_detached_oracle_uses_gap_and_best_positive_cap():
+ policy_rewards = torch.tensor([0.2, 0.4, 0.6], requires_grad=True)
+ policy_advantages = torch.tensor([-0.2, 0.0, 0.2], requires_grad=True)
+ config = DetachedOracleConfig(
+ scale=4.0,
+ match_best_ratio=1.2,
+ match_best_min=0.05,
+ match_best_max=1.0,
+ )
+
+ actual = detached_oracle_advantage(
+ torch.tensor(1.0, requires_grad=True),
+ policy_rewards,
+ policy_advantages,
+ config=config,
+ )
+
+ assert actual.item() == pytest.approx(0.24, abs=1e-6)
+ assert actual.requires_grad is False
+
+
+def test_reward_gap_uses_the_paper_squared_decay():
+ oracle_reward = torch.tensor(1.0)
+ policy_rewards = torch.tensor([0.25, 0.5, 0.75])
+
+ actual = normalized_reward_gap(oracle_reward, policy_rewards)
+ expected = ((1.0 - 0.5) / (1.0 + 1e-6)) ** 2
+
+ assert actual.item() == pytest.approx(expected, abs=1e-7)
+
+
+def test_complete_orarl_keeps_policy_zero_mean_and_sets_oracle_anchor():
+ rewards = torch.tensor([0.2, 0.4, 0.6, 1.0])
+ oracle = np.asarray([False, False, False, True])
+ config = OraRLConfig(
+ directional_gain=DirectionalGainConfig(gamma=0.0),
+ detached_oracle=DetachedOracleConfig(
+ scale=4.0,
+ match_best_ratio=1.2,
+ match_best_min=0.05,
+ match_best_max=1.0,
+ ),
+ )
+ metrics = {}
+
+ advantages = compute_orarl_advantages(
+ rewards,
+ ["group"] * len(rewards),
+ oracle,
+ config=config,
+ metrics_out=metrics,
+ )
+
+ assert abs(float(advantages[:3].mean())) < 1e-7
+ assert torch.allclose(advantages[:3], torch.tensor([-0.2, 0.0, 0.2]))
+ assert advantages[3].item() == pytest.approx(0.24, abs=1e-6)
+ assert metrics["orarl/oracle_cap_applied_fraction"] == 1.0
+
+
+def test_orarl_rejects_groups_without_exactly_one_oracle():
+ with pytest.raises(ValueError, match="one oracle row"):
+ compute_orarl_policy_advantages(
+ torch.tensor([0.0, 1.0]),
+ ["group", "group"],
+ [False, False],
+ )
diff --git a/code/tests/test_algorithm_correction.py b/code/tests/test_algorithm_correction.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a576e4c75dddb98ad464dd117c8c40c8211a739
--- /dev/null
+++ b/code/tests/test_algorithm_correction.py
@@ -0,0 +1,168 @@
+# Copyright 2026 The OraRL Authors
+#
+# 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.
+
+import math
+
+import numpy as np
+import pytest
+import torch
+
+from orarl.algorithm import (
+ CorrectionConfig,
+ PostSelectionReference,
+ apply_post_selection_correction,
+ capture_pre_selection_references,
+ correct_post_selection_group,
+)
+
+
+class FakeDataProto:
+ def __init__(self, batch, non_tensor_batch, meta_info=None):
+ self.batch = batch
+ self.non_tensor_batch = non_tensor_batch
+ self.meta_info = {} if meta_info is None else meta_info
+
+ def __getitem__(self, rows):
+ return FakeDataProto(
+ {key: value[rows] for key, value in self.batch.items()},
+ {key: np.asarray(value)[rows] for key, value in self.non_tensor_batch.items()},
+ self.meta_info,
+ )
+
+
+def test_oracle_projection_preserves_zero_mean_and_nonnegative_anchor():
+ active = torch.tensor([0.8, 0.2, -0.1, 0.1])
+ oracle = torch.tensor([False, False, False, True])
+
+ corrected, metrics = correct_post_selection_group(
+ active,
+ oracle,
+ reference=PostSelectionReference(
+ policy_rms=0.5,
+ sigma_policy=0.2,
+ policy_rows=3,
+ ),
+ config=CorrectionConfig(rms_match=False),
+ )
+
+ assert torch.allclose(
+ corrected,
+ torch.tensor([0.5, -0.1, -0.4, 0.0]),
+ atol=1e-6,
+ )
+ assert abs(float(corrected.sum())) < 1e-6
+ assert corrected[oracle].item() >= 0.0
+ assert metrics["oracle_sign_projection"] == 1.0
+
+
+def test_rms_matching_only_downscales():
+ active = torch.tensor([0.5, -0.3, -0.2, 0.4])
+ oracle = torch.tensor([False, False, False, True])
+
+ corrected, metrics = correct_post_selection_group(
+ active,
+ oracle,
+ reference=PostSelectionReference(
+ policy_rms=0.2,
+ sigma_policy=0.1,
+ policy_rows=3,
+ ),
+ )
+
+ assert abs(float(corrected.mean())) < 1e-6
+ assert math.isclose(
+ float(torch.sqrt(torch.mean(corrected.square()))),
+ 0.2,
+ abs_tol=2e-6,
+ )
+ assert 0.25 <= metrics["rms_scale"] <= 1.0
+
+ small, small_metrics = correct_post_selection_group(
+ torch.tensor([-0.1, 0.1, 0.05]),
+ torch.tensor([False, False, True]),
+ reference=PostSelectionReference(
+ policy_rms=10.0,
+ sigma_policy=1.0,
+ policy_rows=2,
+ ),
+ )
+ assert small_metrics["rms_scale"] == 1.0
+ assert torch.sqrt(torch.mean(small.square())) <= torch.tensor(0.1)
+
+
+def test_small_reward_spread_skips_rms_scaling():
+ active = torch.tensor([0.0, 0.0, 0.0, 0.4])
+ oracle = torch.tensor([False, False, False, True])
+
+ corrected, metrics = correct_post_selection_group(
+ active,
+ oracle,
+ reference=PostSelectionReference(
+ policy_rms=0.0,
+ sigma_policy=0.0,
+ policy_rows=3,
+ ),
+ )
+
+ assert torch.allclose(corrected, torch.tensor([-0.1, -0.1, -0.1, 0.3]))
+ assert metrics["small_sigma_fallback"] == 1.0
+ assert metrics["rms_scale"] == 1.0
+
+
+def test_reference_capture_and_batch_correction_use_preselection_policy_rows():
+ advantages = torch.tensor(
+ [
+ [-1.0, -1.0],
+ [0.1, 0.1],
+ [0.2, 0.2],
+ [0.3, 0.3],
+ [0.2, 0.2],
+ ]
+ )
+ data = FakeDataProto(
+ batch={
+ "advantages": advantages,
+ "response_mask": torch.ones_like(advantages),
+ "token_level_scores": torch.tensor(
+ [
+ [0.0, 0.1],
+ [0.0, 0.2],
+ [0.0, 0.3],
+ [0.0, 0.4],
+ [0.0, 1.0],
+ ]
+ ),
+ },
+ non_tensor_batch={
+ "uid": np.asarray(["group"] * 5, dtype=object),
+ "is_oracle_row": np.asarray(
+ [False, False, False, False, True],
+ dtype=bool,
+ ),
+ "row_id": np.arange(5),
+ },
+ )
+
+ references = capture_pre_selection_references(data)
+ expected_rms = math.sqrt((1.0 + 0.01 + 0.04 + 0.09) / 4.0)
+ assert references["group"].policy_rows == 4
+ assert references["group"].policy_rms == pytest.approx(expected_rms)
+
+ selected = data[[0, 3, 4]]
+ metrics = apply_post_selection_correction(selected, references)
+ active = selected.batch["advantages"][:, 0]
+ assert abs(float(active.mean())) < 1e-6
+ assert active[-1].item() >= 0.0
+ assert metrics["orarl/post_selection_groups"] == 1.0
+ assert metrics["orarl/post_selection_rms_scale"] <= 1.0
diff --git a/code/tests/test_algorithm_selection.py b/code/tests/test_algorithm_selection.py
new file mode 100644
index 0000000000000000000000000000000000000000..299a98d892dc056aade8c9c8a8a20b7454211338
--- /dev/null
+++ b/code/tests/test_algorithm_selection.py
@@ -0,0 +1,169 @@
+# Copyright 2026 The OraRL Authors
+#
+# 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.
+
+import numpy as np
+import pytest
+import torch
+
+from orarl.algorithm import select_sign_balanced_rollouts
+
+
+class FakeDataProto:
+ def __init__(self, batch, non_tensor_batch, meta_info=None):
+ self.batch = batch
+ self.non_tensor_batch = non_tensor_batch
+ self.meta_info = {} if meta_info is None else meta_info
+
+ def __len__(self):
+ return next(iter(self.batch.values())).shape[0]
+
+ def __getitem__(self, rows):
+ return FakeDataProto(
+ {key: value[rows] for key, value in self.batch.items()},
+ {key: np.asarray(value)[rows] for key, value in self.non_tensor_batch.items()},
+ self.meta_info,
+ )
+
+
+def _data(scores, groups, oracle):
+ scalar = torch.tensor(scores, dtype=torch.float32)
+ advantages = scalar.unsqueeze(-1).repeat(1, 2)
+ response_mask = torch.ones_like(advantages)
+ return FakeDataProto(
+ batch={
+ "advantages": advantages,
+ "response_mask": response_mask,
+ "attention_mask": torch.ones(len(scores), 3),
+ },
+ non_tensor_batch={
+ "uid": np.asarray(groups, dtype=object),
+ "is_oracle_row": np.asarray(oracle, dtype=bool),
+ "row_id": np.arange(len(scores)),
+ },
+ meta_info={"original": True},
+ )
+
+
+def test_strict_selection_meets_budget_with_all_fallback_paths():
+ scores = [
+ 0.8,
+ 0.4,
+ -0.9,
+ -0.3,
+ 0.0,
+ 0.5,
+ -0.9,
+ -0.8,
+ -0.7,
+ 0.0,
+ 0.0,
+ 0.5,
+ 0.7,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.5,
+ ]
+ groups = ["a"] * 6 + ["b"] * 6 + ["c"] * 6
+ oracle = [False] * 5 + [True]
+ data = _data(scores, groups, oracle * 3)
+
+ selected, metrics = select_sign_balanced_rollouts(
+ data,
+ keep_per_group=4,
+ positive_quota=1,
+ negative_quota=2,
+ world_size=4,
+ )
+
+ assert selected.non_tensor_batch["row_id"].tolist() == [
+ 0,
+ 2,
+ 3,
+ 5,
+ 6,
+ 7,
+ 8,
+ 11,
+ 12,
+ 13,
+ 14,
+ 17,
+ ]
+ assert len(selected) == 12
+ assert selected.non_tensor_batch["is_oracle_row"].sum() == 3
+ assert metrics["orarl/kept_rows"] == 12.0
+ assert metrics["orarl/oracle_rows_forced"] == 3.0
+ assert metrics["orarl/positive_policy_rows_kept"] == 2.0
+ assert metrics["orarl/negative_policy_rows_kept"] == 5.0
+ assert metrics["orarl/zero_policy_rows_kept"] == 2.0
+ assert metrics["orarl/cross_sign_fallback_rows"] == 1.0
+ assert metrics["orarl/zero_fallback_rows"] == 2.0
+ assert selected.meta_info["global_token_num"] == [3.0] * 12
+ assert data.meta_info == {"original": True}
+ assert all(key.startswith("orarl/") for key in metrics)
+
+
+def test_selection_uses_sequence_mean_instead_of_response_length():
+ data = _data(
+ [0.3, 0.2, -0.4, -0.1, 0.0, 0.5],
+ ["group"] * 6,
+ [False, False, False, False, False, True],
+ )
+ data.batch["response_mask"][0, 1] = 0.0
+
+ selected, _ = select_sign_balanced_rollouts(
+ data,
+ positive_quota=1,
+ negative_quota=1,
+ world_size=1,
+ n_rollouts=6,
+ prune_ratio=0.5,
+ )
+
+ assert selected.non_tensor_batch["row_id"].tolist() == [0, 2, 5]
+
+
+def test_selection_checks_world_size_before_slicing():
+ data = _data(
+ [0.3, 0.2, -0.4, -0.1, 0.0, 0.5],
+ ["group"] * 6,
+ [False, False, False, False, False, True],
+ )
+
+ with pytest.raises(RuntimeError, match="not divisible"):
+ select_sign_balanced_rollouts(
+ data,
+ keep_per_group=4,
+ positive_quota=1,
+ negative_quota=2,
+ world_size=3,
+ )
+
+
+def test_selection_requires_exactly_one_oracle_per_group():
+ data = _data(
+ [0.3, 0.2, -0.4, -0.1],
+ ["group"] * 4,
+ [False, False, False, False],
+ )
+
+ with pytest.raises(ValueError, match="exactly one oracle"):
+ select_sign_balanced_rollouts(
+ data,
+ keep_per_group=3,
+ positive_quota=1,
+ negative_quota=1,
+ )
diff --git a/code/tests/test_builtin_reward_adapters.py b/code/tests/test_builtin_reward_adapters.py
new file mode 100644
index 0000000000000000000000000000000000000000..9cfb6fddc396ca35d691a5fdb15b88e253d2576f
--- /dev/null
+++ b/code/tests/test_builtin_reward_adapters.py
@@ -0,0 +1,249 @@
+from __future__ import annotations
+
+import importlib
+import importlib.util
+from typing import Any
+
+import numpy as np
+import pytest
+
+from orarl.rewards import RewardRouter, RouterSettings, TaskFamily
+
+SEGMENTATION_GT = {
+ "boxes": [100, 100, 400, 400],
+ "positive_points": [[150, 150], [250, 250], [350, 350]],
+ "negative_points": [[0, 0], [700, 700], [900, 100]],
+}
+
+TASK_CASES = [
+ (
+ {"problem_type": "temporal grounding", "ground_truth": {"time": [1, 3]}},
+ "8 to 9 ",
+ ),
+ (
+ {
+ "problem_type": "tracking",
+ "ground_truth": {
+ "boxes": {
+ "1": [0, 0, 10, 10],
+ "2": [10, 10, 20, 20],
+ }
+ },
+ },
+ '{"boxes":{"1":[0,0,10,10],"2":[10,10,20,20]}}',
+ ),
+ (
+ {
+ "problem_type": "segmentation",
+ "data_type": "image",
+ "ground_truth": SEGMENTATION_GT,
+ },
+ '{"boxes":[100,100,400,400]} ',
+ ),
+ (
+ {
+ "problem_type": "spatial grounding",
+ "ground_truth": {"bbox_2d": [10, 20, 40, 60]},
+ },
+ "[10,20,40,60]",
+ ),
+ (
+ {
+ "problem_type": "spatial-temporal grounding",
+ "ground_truth": {
+ "time": [1, 2],
+ "boxes": {
+ "1": [0, 0, 10, 10],
+ "2": [10, 10, 20, 20],
+ },
+ },
+ },
+ '{"time":[1,2],"boxes":{"1":[50,50,60,60]}} ',
+ ),
+ (
+ {"problem_type": "object_counting", "ground_truth": "5"},
+ "12 ",
+ ),
+ (
+ {"problem_type": "video_qa_mc", "ground_truth": "H "},
+ "A ",
+ ),
+]
+
+
+@pytest.mark.parametrize(("sample", "wrong_response"), TASK_CASES)
+def test_default_router_oracles_maximize_all_families(sample, wrong_response):
+ router = RewardRouter()
+ oracle_response = router.build_oracle_response(sample)
+
+ oracle_score = router.compute_reward(sample, oracle_response)
+ wrong_score = router.compute_reward(sample, wrong_response)
+
+ assert oracle_score["overall"] == pytest.approx(1.0)
+ assert oracle_score["format"] == 1.0
+ assert wrong_score["overall"] < oracle_score["overall"]
+
+
+def test_default_paths_are_packaged_and_importable():
+ paths = RouterSettings().module_paths
+
+ assert set(paths) == set(TaskFamily)
+ assert all(path.startswith("orarl.") for path in paths.values())
+ assert all(importlib.util.find_spec(path) is not None for path in paths.values())
+
+
+def test_default_router_never_requests_an_external_example_module():
+ imported: list[str] = []
+
+ def guarded_import(module_path: str):
+ assert not module_path.startswith("examples.")
+ imported.append(module_path)
+ return importlib.import_module(module_path)
+
+ router = RewardRouter(importer=guarded_import)
+ for sample, _ in TASK_CASES:
+ response = router.build_oracle_response(sample)
+ assert router.compute_reward(sample, response)["overall"] == pytest.approx(1.0)
+
+ assert len(imported) == len(TaskFamily)
+ assert all(path.startswith("orarl.") for path in imported)
+
+
+def test_tracking_missing_frame_contributes_zero():
+ router = RewardRouter()
+ sample = {
+ "problem_type": "tracking",
+ "ground_truth": {
+ "boxes": {
+ "1": [0, 0, 10, 10],
+ "2": [10, 10, 20, 20],
+ }
+ },
+ }
+ response = '{"boxes":{"1":[0,0,10,10]}} '
+
+ score = router.compute_reward(sample, response)
+
+ assert score["miou"] == pytest.approx(0.5)
+ assert score["overall"] == pytest.approx(0.5)
+
+
+def test_stvg_combines_temporal_iou_with_strict_spatial_iou():
+ router = RewardRouter()
+ sample = {
+ "problem_type": "stvg",
+ "ground_truth": {
+ "time": [1, 3],
+ "boxes": {
+ "1": [0, 0, 10, 10],
+ "2": [10, 10, 20, 20],
+ },
+ },
+ }
+ response = '{"time":[1,3],"boxes":{"1":[0,0,10,10]}} '
+
+ score = router.compute_reward(sample, response)
+
+ assert score["tiou"] == 1.0
+ assert score["siou_strict"] == pytest.approx(0.5)
+ assert score["overall"] == pytest.approx(0.2 * 1.0 + 0.8 * 0.5)
+
+
+def _rle_counts(mask: np.ndarray) -> list[int]:
+ flattened = mask.T.reshape(-1)
+ counts: list[int] = []
+ previous = 0
+ run = 0
+ for value in flattened:
+ current = int(value)
+ if current == previous:
+ run += 1
+ else:
+ counts.append(run)
+ run = 1
+ previous = current
+ counts.append(run)
+ return counts
+
+
+def _compressed_counts(counts: list[int]) -> str:
+ encoded: list[str] = []
+ for index, count in enumerate(counts):
+ value = count - counts[index - 2] if index > 2 else count
+ more = True
+ while more:
+ code = value & 0x1F
+ value >>= 5
+ more = value != (-1 if code & 0x10 else 0)
+ if more:
+ code |= 0x20
+ encoded.append(chr(code + 48))
+ return "".join(encoded)
+
+
+def _mask_ground_truth() -> dict[str, Any]:
+ return {
+ "boxes": [200, 200, 600, 600],
+ "positive_points": [[300, 300], [400, 400], [500, 500]],
+ "negative_points": [[50, 50], [800, 800], [950, 500]],
+ }
+
+
+def test_segmentation_uses_image_mask_and_proxy_fallback_deterministically():
+ mask = np.zeros((100, 100), dtype=np.uint8)
+ mask[20:60, 20:60] = 1
+ sample = {
+ "problem_type": "segmentation",
+ "data_type": "image",
+ "ground_truth": _mask_ground_truth(),
+ "segmentation_output": {
+ "size": [100, 100],
+ "counts": _rle_counts(mask),
+ },
+ }
+ router = RewardRouter()
+ response = router.build_oracle_response(sample)
+
+ mask_score = router.compute_reward(sample, response)
+ proxy_sample = {key: value for key, value in sample.items() if key != "segmentation_output"}
+ first_proxy = router.compute_reward(proxy_sample, response)
+ second_proxy = router.compute_reward(proxy_sample, response)
+
+ assert mask_score["overall"] == pytest.approx(1.0)
+ assert mask_score["mask_aware_used"] == 1.0
+ assert first_proxy["overall"] == pytest.approx(1.0)
+ assert first_proxy == second_proxy
+
+
+def test_segmentation_decodes_compressed_video_rle_using_media_metadata():
+ mask = np.zeros((100, 100), dtype=np.uint8)
+ mask[20:60, 20:60] = 1
+ empty = np.zeros_like(mask)
+ target = {**_mask_ground_truth(), "time": 1.0}
+ sample = {
+ "problem_type": "segmentation",
+ "data_type": "video",
+ "ground_truth": target,
+ "videos": [{"video": "clip.mp4", "fps": 10}],
+ "segmentation_output": {
+ "frames": ["0", "10"],
+ "segmentation_rle": {
+ "0": {
+ "size": [100, 100],
+ "counts": _compressed_counts(_rle_counts(empty)),
+ },
+ "10": {
+ "size": [100, 100],
+ "counts": _compressed_counts(_rle_counts(mask)),
+ },
+ },
+ },
+ }
+ router = RewardRouter()
+ response = router.build_oracle_response(sample)
+
+ score = router.compute_reward(sample, response)
+
+ assert score["overall"] == pytest.approx(1.0)
+ assert score["mask_aware_used"] == 1.0
+ assert score["mask_box_iou"] == pytest.approx(1.0)
diff --git a/code/tests/test_cli_evaluate.py b/code/tests/test_cli_evaluate.py
new file mode 100644
index 0000000000000000000000000000000000000000..432ec272a9a9893ed81c74d13847071a34c3623b
--- /dev/null
+++ b/code/tests/test_cli_evaluate.py
@@ -0,0 +1,1071 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import sys
+import types
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(RELEASE_ROOT))
+
+from orarl.cli import evaluate # noqa: E402
+from orarl.evaluation.layout import ( # noqa: E402
+ annotation_path as canonical_annotation_path,
+)
+from orarl.evaluation.layout import media_directory # noqa: E402
+
+
+def _write_jsonl(path: Path, records: list[dict[str, object]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ "".join(json.dumps(record, sort_keys=True) + "\n" for record in records),
+ encoding="utf-8",
+ )
+
+
+def _canonical_root(
+ tmp_path: Path,
+ specs: list[dict[str, object]],
+) -> Path:
+ root = tmp_path / "canonical"
+ manifests: list[dict[str, object]] = []
+ assets: list[dict[str, object]] = []
+ for spec in specs:
+ task = str(spec["task"])
+ benchmark = str(spec.get("benchmark", task))
+ split = str(spec.get("split", "test"))
+ video_bytes = f"{benchmark}/{split}".encode()
+ video_digest = hashlib.sha256(video_bytes).hexdigest()
+ video_path = f"{media_directory(benchmark, 'videos')}/{video_digest}.mp4"
+ (root / video_path).parent.mkdir(parents=True, exist_ok=True)
+ (root / video_path).write_bytes(video_bytes)
+
+ row = {
+ "schema_version": 1,
+ "eval_task": task,
+ "sample_id": f"{benchmark}-{split}",
+ "benchmark": benchmark,
+ "split": split,
+ "problem": "What happens?",
+ "answer": "A",
+ "images": [],
+ "videos": [video_path],
+ "problem_type": task,
+ "source": benchmark,
+ "task_payload": {},
+ "evaluation": {"metric_profile": "fixture"},
+ }
+ annotation_path = canonical_annotation_path(benchmark, split)
+ _write_jsonl(root / annotation_path, [row])
+ annotation_bytes = (root / annotation_path).read_bytes()
+ annotation_digest = hashlib.sha256(annotation_bytes).hexdigest()
+
+ manifest = {
+ "schema_version": 1,
+ "benchmark": benchmark,
+ "split": split,
+ "task": task,
+ "family": str(spec.get("family", task)),
+ "annotation_path": annotation_path,
+ "media_paths": [media_directory(benchmark, "videos")],
+ "artifact_paths": [],
+ "expected_count": 1,
+ "license": "fixture-only",
+ "source_url": f"https://example.org/{benchmark}",
+ "redistribution_authorized": True,
+ "evaluation": spec.get(
+ "evaluation",
+ {
+ "prompt_profile": "fixture",
+ "parser_profile": "fixture",
+ "metric_profile": "fixture",
+ },
+ ),
+ "checksums": {annotation_path: annotation_digest},
+ }
+ if "preprocessing" in spec:
+ manifest["preprocessing"] = spec["preprocessing"]
+ if "legacy_environment" in spec:
+ manifest["legacy_environment"] = spec["legacy_environment"]
+ manifests.append(manifest)
+ assets.extend(
+ [
+ {
+ "path": annotation_path,
+ "sha256": annotation_digest,
+ "bytes": len(annotation_bytes),
+ "kind": "annotations",
+ "benchmark": benchmark,
+ "license": "fixture-only",
+ },
+ {
+ "path": video_path,
+ "sha256": video_digest,
+ "bytes": len(video_bytes),
+ "kind": "videos",
+ "benchmark": benchmark,
+ "license": "fixture-only",
+ },
+ ]
+ )
+
+ manifests.sort(key=lambda record: (str(record["benchmark"]), str(record["split"])))
+ assets.sort(key=lambda record: str(record["path"]))
+ _write_jsonl(root / "datasets.jsonl", manifests)
+ _write_jsonl(root / "assets.jsonl", assets)
+ return root
+
+
+def _canonical_arguments(tmp_path: Path, root: str, tasks: str = "vsi") -> list[str]:
+ model = tmp_path / "canonical-model"
+ model.mkdir(exist_ok=True)
+ evaluator = tmp_path / "canonical-eval.sh"
+ evaluator.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
+ return [
+ "--model",
+ str(model),
+ "--tasks",
+ tasks,
+ "--dataset",
+ root,
+ "--evaluator",
+ str(evaluator),
+ "--summary",
+ str(tmp_path / "canonical-summary.json"),
+ ]
+
+
+def _base_arguments(tmp_path: Path, tasks: str = "vsi") -> list[str]:
+ model = tmp_path / "model"
+ model.mkdir()
+ evaluator = tmp_path / "eval.sh"
+ evaluator.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
+ data_root = tmp_path / "benchmarks"
+ data_root.mkdir()
+ return [
+ "--model",
+ str(model),
+ "--tasks",
+ tasks,
+ "--data-root",
+ str(data_root),
+ "--evaluator",
+ str(evaluator),
+ "--summary",
+ str(tmp_path / "aggregate.json"),
+ ]
+
+
+def test_released_temporal_profiles_match_the_paper_evaluation() -> None:
+ records = [
+ json.loads(line)
+ for line in (RELEASE_ROOT / "data/eval/datasets.jsonl")
+ .read_text(encoding="utf-8")
+ .splitlines()
+ if line.strip()
+ ]
+ records = [
+ record for record in records if record["task"] == "temporal_grounding"
+ ]
+
+ assert {record["split"] for record in records} == {
+ "activitynet_timelens",
+ "charades_timelens",
+ "qvhighlights_timelens",
+ }
+ assert all(
+ record["preprocessing"]
+ == {
+ "fps": 4,
+ "max_frames": 2048,
+ "max_pixels": 409600,
+ "min_tokens": 1,
+ "total_tokens": 128000,
+ }
+ for record in records
+ )
+
+
+def test_eval_builds_restricted_parent_command(tmp_path: Path) -> None:
+ namespace = evaluate.create_parser().parse_args(_base_arguments(tmp_path, "vsi,mindcube"))
+ command, environment, tasks, _, summary = evaluate.build_launch(namespace)
+
+ assert namespace.dry_run is True
+ assert tasks == ["vsi", "mindcube"]
+ assert command[:2] == ["bash", str((tmp_path / "eval.sh").resolve())]
+ assert command[command.index("--tasks") + 1] == "vsi,mindcube"
+ assert environment["MINDCUBE_EXPECTED_SAMPLES"] == "1050"
+ assert environment["VSI_DATA_FILE"].endswith("vsi/annotations.jsonl")
+ assert summary == (tmp_path / "aggregate.json").resolve()
+
+
+def test_video_qa_alias_expands_to_documented_tasks(tmp_path: Path) -> None:
+ namespace = evaluate.create_parser().parse_args(_base_arguments(tmp_path, "video_qa"))
+ _, _, tasks, _, _ = evaluate.build_launch(namespace)
+ assert tuple(tasks) == (
+ "videomme",
+ "videommev2",
+ "mvbench",
+ "mmvu",
+ "videoholmes",
+ "longvideobench",
+ "mlvu",
+ )
+
+
+def test_selected_assets_follow_manifested_video_qa_roots() -> None:
+ records = [
+ {
+ "benchmark": "videomme",
+ "annotation_path": "annotations/video_qa/videomme/test.jsonl",
+ "media_paths": ["media/video_qa/videomme/videos"],
+ "artifact_paths": ["artifacts/video_qa/videomme"],
+ }
+ ]
+ paths = (
+ "annotations/video_qa/videomme/test.jsonl",
+ "media/video_qa/videomme/videos/test/clip.mp4",
+ "artifacts/video_qa/videomme/test/cache.pt",
+ "media/video_qa/videommev2/videos/test/other.mp4",
+ )
+ assets = [{"path": path} for path in paths]
+
+ selected = evaluate._selected_asset_records(assets, records)
+
+ assert [record["path"] for record in selected] == list(paths[:3])
+
+
+def test_videommev2_accepts_bounded_smoke_test(tmp_path: Path) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "videommev2",
+ "split": "test",
+ "legacy_environment": {
+ "VIDEOMMEV2_SETTING": "videommev2-fixture",
+ },
+ }
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, str(root), tasks="videommev2"),
+ "--max-samples",
+ "8",
+ ]
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["VIDEOMMEV2_MAX_SAMPLES"] == "8"
+ assert environment["VIDEOMMEV2_DATA_FILE"] == str(
+ (root / "annotations/video_qa/videommev2/test.jsonl").resolve()
+ )
+
+
+def test_videomme_accepts_bounded_smoke_test(tmp_path: Path) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "videomme",
+ "split": "test",
+ "legacy_environment": {
+ "VIDEOMME_SETTING": "videomme-fixture",
+ },
+ }
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, str(root), tasks="videomme"),
+ "--max-samples",
+ "8",
+ ]
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["VIDEOMME_MAX_SAMPLES"] == "8"
+ assert environment["VIDEOMME_DATA_FILE"] == str(
+ (root / "annotations/video_qa/videomme/test.jsonl").resolve()
+ )
+
+
+@pytest.mark.parametrize(
+ ("task", "prefix"),
+ [
+ ("mvbench", "MVBENCH"),
+ ("mmvu", "MMVU"),
+ ("videoholmes", "VIDEOHOLMES"),
+ ("longvideobench", "LONGVIDEOBENCH"),
+ ("mlvu", "MLVU"),
+ ],
+)
+def test_other_video_qa_tasks_accept_bounded_smoke_tests(
+ tmp_path: Path,
+ task: str,
+ prefix: str,
+) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": task,
+ "split": "test",
+ "legacy_environment": {
+ f"{prefix}_SETTING": f"{task}-fixture",
+ },
+ }
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, str(root), tasks=task),
+ "--max-samples",
+ "8",
+ ]
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment[f"{prefix}_MAX_SAMPLES"] == "8"
+ assert environment[f"{prefix}_DATA_FILE"] == str(
+ (root / f"annotations/video_qa/{task}/test.jsonl").resolve()
+ )
+
+
+def test_eval_rejects_unknown_task(tmp_path: Path) -> None:
+ namespace = evaluate.create_parser().parse_args(_base_arguments(tmp_path, "unknown"))
+ with pytest.raises(evaluate.CliError, match="unsupported task"):
+ evaluate.build_launch(namespace)
+
+
+def test_eval_rejects_benchmark_outside_paper_suite(tmp_path: Path) -> None:
+ namespace = evaluate.create_parser().parse_args(_base_arguments(tmp_path, "videommmu"))
+ with pytest.raises(evaluate.CliError, match="unsupported task"):
+ evaluate.build_launch(namespace)
+
+
+def test_evaluator_discovery_uses_installed_runtime_checkout(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ runtime_root = tmp_path / "runtime"
+ package = runtime_root / "verl"
+ package.mkdir(parents=True)
+ init_file = package / "__init__.py"
+ init_file.touch()
+ evaluator = runtime_root / "eval" / "task" / "eval.sh"
+ evaluator.parent.mkdir(parents=True)
+ evaluator.touch()
+ monkeypatch.setattr(
+ evaluate,
+ "find_spec",
+ lambda _: SimpleNamespace(
+ origin=str(init_file),
+ submodule_search_locations=[str(package)],
+ ),
+ )
+
+ assert evaluate._discover_evaluator(None) == evaluator.resolve()
+
+
+def test_installed_evaluator_uses_wheel_data_files(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ evaluator = tmp_path / "share" / "orarl" / "eval" / "task" / "eval.sh"
+ evaluator.parent.mkdir(parents=True)
+ evaluator.touch()
+ package_distribution = SimpleNamespace(
+ files=["../../../share/orarl/eval/task/eval.sh"],
+ locate_file=lambda _: evaluator,
+ )
+ monkeypatch.setattr(evaluate, "distribution", lambda _: package_distribution)
+
+ assert evaluate._installed_evaluator() == evaluator.resolve()
+
+
+def test_explicit_missing_evaluator_is_not_silently_ignored(
+ tmp_path: Path,
+) -> None:
+ with pytest.raises(evaluate.CliError, match="evaluator does not exist"):
+ evaluate._discover_evaluator(str(tmp_path / "missing.sh"))
+
+
+def test_task_config_is_allowlisted_and_resolves_relative_paths(tmp_path: Path) -> None:
+ arguments = _base_arguments(tmp_path)
+ config = tmp_path / "vsi.yaml"
+ config.write_text(
+ "task: vsi\n"
+ "environment:\n"
+ " VSI_DATA_FILE: annotations.jsonl\n"
+ " VSI_PREPROCESSED_VIDEO_DIR: media\n"
+ " EVAL_CONDA_ENV: orarl-eval\n",
+ encoding="utf-8",
+ )
+ arguments.extend(("--task-config", f"vsi={config}"))
+ namespace = evaluate.create_parser().parse_args(arguments)
+ command, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ root = (tmp_path / "benchmarks").resolve()
+ assert environment["VSI_DATA_FILE"] == str(root / "annotations.jsonl")
+ assert environment["VSI_PREPROCESSED_VIDEO_DIR"] == str(root / "media")
+ assert "EVAL_CONDA_ENV" not in environment
+ assert command[command.index("--env") + 1] == "orarl-eval"
+
+
+def test_canonical_local_root_resolves_profiles_and_paths(tmp_path: Path) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "vsi",
+ "legacy_environment": {
+ "VSI_DATA_FILE": "annotations/spatial_intelligence/vsi/test.jsonl",
+ "VSI_PREPROCESSED_VIDEO_DIR": "media/spatial_intelligence/vsi/videos",
+ "VSI_TASK_FILTER": ["distance", "direction"],
+ },
+ }
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ _canonical_arguments(tmp_path, str(root))
+ )
+
+ _, environment, tasks, _, _ = evaluate.build_launch(namespace)
+
+ assert tasks == ["vsi"]
+ assert environment["ORARL_EVAL_DATA_ROOT"] == str(root.resolve())
+ assert environment["ORARL_EVAL_DATASETS_JSONL"] == str(
+ (root / "datasets.jsonl").resolve()
+ )
+ assert environment["VSI_DATA_FILE"] == str(
+ (root / "annotations/spatial_intelligence/vsi/test.jsonl").resolve()
+ )
+ assert environment["VSI_PREPROCESSED_VIDEO_DIR"] == str(
+ (root / "media/spatial_intelligence/vsi/videos").resolve()
+ )
+ assert environment["VSI_EXPECTED_SAMPLES"] == "1"
+ assert environment["VSI_TASK_FILTER"] == "distance,direction"
+
+
+def test_canonical_revsi_uses_all_frame_profile_and_assets(tmp_path: Path) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "revsi",
+ "family": "spatial_intelligence",
+ "legacy_environment": {
+ "REVSI_FRAME_BUDGET": "all",
+ "REVSI_MAX_FRAMES": 128,
+ "REVSI_EXACT_NFRAMES": True,
+ "REVSI_VIDEO_TOTAL_PIXELS": 16777216,
+ },
+ }
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, str(root), tasks="revsi"),
+ "--batch-size",
+ "4",
+ "--max-samples",
+ "8",
+ ]
+ )
+
+ _, environment, tasks, _, _ = evaluate.build_launch(namespace)
+
+ assert tasks == ["revsi"]
+ assert environment["REVSI_DATA_FILE"] == str(
+ (root / "annotations/spatial_intelligence/revsi/test.jsonl").resolve()
+ )
+ assert environment["REVSI_VIDEO_ROOT"] == str(root.resolve())
+ assert environment["REVSI_EXPECTED_SAMPLES"] == "1"
+ assert environment["REVSI_FRAME_BUDGET"] == "all"
+ assert environment["REVSI_EXACT_NFRAMES"] == "true"
+ assert environment["REVSI_BATCH_SIZE"] == "4"
+ assert environment["REVSI_MAX_SAMPLES"] == "8"
+
+
+def test_canonical_segmentation_supports_bounded_smoke_test(tmp_path: Path) -> None:
+ splits = ("mevis", "reasonvos", "refcoco", "refcocog", "refcocop")
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "segmentation",
+ "split": split,
+ "family": "segmentation",
+ "legacy_environment": {
+ "SEGMENTATION_RUN_SAM2": False,
+ "SEGMENTATION_MAX_FRAMES": 128,
+ },
+ }
+ for split in splits
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, str(root), tasks="segmentation"),
+ "--max-samples",
+ "1",
+ ]
+ )
+
+ _, environment, tasks, _, _ = evaluate.build_launch(namespace)
+
+ assert tasks == ["segmentation"]
+ assert environment["SEGMENTATION_DATASETS"] == ",".join(splits)
+ assert environment["SEGMENTATION_DATA_ROOT"] == str(root.resolve())
+ assert environment["SEGMENTATION_MAX_SAMPLES"] == "1"
+ assert environment["SEGMENTATION_RUN_SAM2"] == "false"
+
+
+def test_canonical_metadata_can_use_a_separate_asset_root(tmp_path: Path) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "vsi",
+ "legacy_environment": {
+ "VSI_PREPROCESSED_VIDEO_DIR": "media/spatial_intelligence/vsi/videos",
+ },
+ }
+ ],
+ )
+ asset_root = tmp_path / "external-assets"
+ asset_root.mkdir()
+ (root / "assets.jsonl").rename(asset_root / "assets.jsonl")
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, str(root)),
+ "--asset-root",
+ str(asset_root),
+ ]
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["ORARL_EVAL_DATA_ROOT"] == str(root.resolve())
+ assert environment["ORARL_EVAL_ASSET_ROOT"] == str(asset_root.resolve())
+ assert environment["VSI_PREPROCESSED_VIDEO_DIR"] == str(
+ (asset_root / "media/spatial_intelligence/vsi/videos").resolve()
+ )
+
+
+def test_temporal_grounding_accepts_per_split_preprocessing_profiles(
+ tmp_path: Path,
+) -> None:
+ datasets = "charades_timelens,activitynet_timelens,qvhighlights_timelens"
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "temporal_grounding",
+ "split": split,
+ "preprocessing": {
+ "fps": fps,
+ "min_tokens": 1,
+ "max_frames": 2048,
+ "max_pixels": 409600,
+ "total_tokens": 128000,
+ },
+ "legacy_environment": {"TIMELENS_DATASETS": datasets},
+ }
+ for split, fps in (
+ ("charades_timelens", 4),
+ ("activitynet_timelens", 2),
+ ("qvhighlights_timelens", 2),
+ )
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ _canonical_arguments(tmp_path, str(root), tasks="temporal_grounding")
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["TIMELENS_DATASETS"] == datasets
+ assert environment["TIMELENS_BENCH_DIR"] == str(
+ (root / "annotations/temporal_grounding").resolve()
+ )
+
+
+def test_temporal_grounding_can_select_sta_for_a_bounded_smoke_test(
+ tmp_path: Path,
+) -> None:
+ datasets = "charades_timelens,activitynet_timelens,qvhighlights_timelens"
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "temporal_grounding",
+ "split": split,
+ "preprocessing": {
+ "fps": fps,
+ "min_tokens": 1,
+ "max_frames": 2048,
+ "max_pixels": 409600,
+ "total_tokens": 128000,
+ },
+ "legacy_environment": {"TIMELENS_DATASETS": datasets},
+ }
+ for split, fps in (
+ ("charades_timelens", 4),
+ ("activitynet_timelens", 2),
+ ("qvhighlights_timelens", 2),
+ )
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(
+ tmp_path,
+ str(root),
+ tasks="temporal_grounding",
+ ),
+ "--splits",
+ "charades_timelens",
+ "--max-samples",
+ "8",
+ ]
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["TIMELENS_DATASETS"] == "charades_timelens"
+ assert environment["TIMELENS_MAX_SAMPLES"] == "8"
+ assert environment["TIMELENS_FPS"] == "4"
+ assert environment["TIMELENS_MIN_TOKENS"] == "1"
+ assert environment["TIMELENS_MAX_FRAMES"] == "2048"
+ assert environment["TIMELENS_MAX_PIXELS"] == "409600"
+ assert environment["TIMELENS_TOTAL_TOKENS"] == "128000"
+
+
+def test_spatial_grounding_maps_canonical_split_to_evaluator_name(
+ tmp_path: Path,
+) -> None:
+ evaluator_datasets = "refcoco-val,refcoco+-testA"
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "spatial_grounding",
+ "split": split,
+ "preprocessing": {"coordinate_system": "norm1000"},
+ "legacy_environment": {
+ "SPATIAL_GROUNDING_DATASETS": evaluator_datasets,
+ },
+ }
+ for split in ("refcoco_val", "refcocop_test_a")
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(
+ tmp_path,
+ str(root),
+ tasks="spatial_grounding",
+ ),
+ "--splits",
+ "refcocop_test_a",
+ "--max-samples",
+ "16",
+ ]
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["SPATIAL_GROUNDING_DATASETS"] == "refcoco+-testA"
+ assert environment["SPATIAL_GROUNDING_MAX_SAMPLES"] == "16"
+ assert environment["SPATIAL_GROUNDING_BENCH_DIR"] == str(
+ (root / "annotations/spatial_grounding").resolve()
+ )
+
+
+def test_tracking_maps_canonical_split_to_evaluator_name(tmp_path: Path) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "tracking",
+ "split": "got10k",
+ "preprocessing": {
+ "tracking_fps": 1,
+ "tracking_max_frames": 32,
+ },
+ "legacy_environment": {
+ "TRACKING_DATASETS": "eval_got10k",
+ },
+ }
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, str(root), tasks="tracking"),
+ "--max-samples",
+ "8",
+ ]
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["TRACKING_DATASETS"] == "eval_got10k"
+ assert environment["TRACKING_MAX_SAMPLES"] == "8"
+ assert environment["TRACKING_BENCH_DIR"] == str(
+ (root / "annotations/tracking").resolve()
+ )
+
+
+def test_stvg_maps_canonical_split_and_smoke_limit(tmp_path: Path) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "stvg",
+ "split": "stvg",
+ "preprocessing": {
+ "stvg_fps": 2,
+ "stvg_max_frames": 128,
+ },
+ "legacy_environment": {
+ "STVG_DATASETS": "eval_stvg",
+ },
+ }
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, str(root), tasks="stvg"),
+ "--max-samples",
+ "8",
+ ]
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["STVG_DATASETS"] == "eval_stvg"
+ assert environment["STVG_MAX_SAMPLES"] == "8"
+ assert environment["STVG_BENCH_DIR"] == str(
+ (root / "annotations/stvg").resolve()
+ )
+
+
+def test_canonical_dry_run_needs_only_valid_manifests(tmp_path: Path) -> None:
+ root = _canonical_root(tmp_path, [{"task": "vsi"}])
+ for path in (root / "annotations").rglob("*"):
+ if path.is_file():
+ path.unlink()
+ for path in (root / "media").rglob("*"):
+ if path.is_file():
+ path.unlink()
+ namespace = evaluate.create_parser().parse_args(
+ _canonical_arguments(tmp_path, str(root))
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["VSI_DATA_FILE"].endswith(
+ "annotations/spatial_intelligence/vsi/test.jsonl"
+ )
+
+
+def test_canonical_hf_dataset_uses_manifest_only_snapshot_for_dry_run(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ root = _canonical_root(tmp_path, [{"task": "vsi"}])
+ calls: list[dict[str, object]] = []
+ module = types.ModuleType("huggingface_hub")
+
+ def snapshot_download(**kwargs: object) -> str:
+ calls.append(dict(kwargs))
+ return str(root)
+
+ module.snapshot_download = snapshot_download # type: ignore[attr-defined]
+ monkeypatch.setitem(sys.modules, "huggingface_hub", module)
+ cache_dir = tmp_path / "hf-cache"
+ arguments = _canonical_arguments(tmp_path, "org/evaluation-data")
+ arguments.extend(
+ (
+ "--revision",
+ "release",
+ "--cache-dir",
+ str(cache_dir),
+ "--local-files-only",
+ )
+ )
+ namespace = evaluate.create_parser().parse_args(arguments)
+
+ evaluate.build_launch(namespace)
+
+ assert calls == [
+ {
+ "repo_id": "org/evaluation-data",
+ "repo_type": "dataset",
+ "revision": "release",
+ "cache_dir": str(cache_dir),
+ "local_files_only": True,
+ "allow_patterns": ["datasets.jsonl", "assets.jsonl"],
+ }
+ ]
+
+
+def test_canonical_hf_run_downloads_only_selected_task_assets(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [{"task": "vsi"}, {"task": "mmsi"}],
+ )
+ calls: list[dict[str, object]] = []
+ module = types.ModuleType("huggingface_hub")
+
+ def snapshot_download(**kwargs: object) -> str:
+ calls.append(dict(kwargs))
+ return str(root)
+
+ module.snapshot_download = snapshot_download # type: ignore[attr-defined]
+ monkeypatch.setitem(sys.modules, "huggingface_hub", module)
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, "org/evaluation-data"),
+ "--run",
+ ]
+ )
+
+ evaluate.build_launch(namespace)
+
+ assert len(calls) == 2
+ selected = calls[1]["allow_patterns"]
+ assert isinstance(selected, list)
+ assert "annotations/spatial_intelligence/vsi/test.jsonl" in selected
+ assert any(
+ str(path).startswith("media/spatial_intelligence/vsi/")
+ for path in selected
+ )
+ assert not any(
+ str(path).startswith("media/spatial_intelligence/mmsi/")
+ for path in selected
+ )
+
+
+def test_canonical_dataset_requires_task_coverage(tmp_path: Path) -> None:
+ root = _canonical_root(tmp_path, [{"task": "vsi"}])
+ namespace = evaluate.create_parser().parse_args(
+ _canonical_arguments(tmp_path, str(root), "vsi,mmsi")
+ )
+
+ with pytest.raises(evaluate.CliError, match="no configuration record.*mmsi"):
+ evaluate.build_launch(namespace)
+
+
+def test_canonical_dataset_rejects_conflicting_structured_profiles(
+ tmp_path: Path,
+) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "tracking",
+ "split": "got10k",
+ "evaluation": {"metric_profile": "one"},
+ },
+ {
+ "task": "tracking",
+ "split": "lasot",
+ "evaluation": {"metric_profile": "two"},
+ },
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ _canonical_arguments(tmp_path, str(root), "tracking")
+ )
+
+ with pytest.raises(evaluate.CliError, match="conflicting canonical evaluation"):
+ evaluate.build_launch(namespace)
+
+
+def test_canonical_structured_records_merge_identical_profiles(tmp_path: Path) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {"task": "tracking", "split": "got10k"},
+ {"task": "tracking", "split": "lasot"},
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ _canonical_arguments(tmp_path, str(root), "tracking")
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["TRACKING_DATASETS"] == "eval_got10k,lasot"
+ assert environment["TRACKING_BENCH_DIR"] == str(
+ (root / "annotations/tracking").resolve()
+ )
+ assert environment["TRACKING_BASE_PREFIX"] == str(root.resolve())
+
+
+def test_canonical_dataset_rejects_unrelated_profile_settings(tmp_path: Path) -> None:
+ root = _canonical_root(
+ tmp_path,
+ [
+ {
+ "task": "mmsi",
+ "legacy_environment": {
+ "VSI_DATA_FILE": "annotations/spatial_intelligence/mmsi/test.jsonl",
+ },
+ }
+ ],
+ )
+ namespace = evaluate.create_parser().parse_args(
+ _canonical_arguments(tmp_path, str(root), "mmsi")
+ )
+
+ with pytest.raises(evaluate.CliError, match="unrelated canonical profile"):
+ evaluate.build_launch(namespace)
+
+
+def test_canonical_run_verifies_asset_sizes_without_rehashing(tmp_path: Path) -> None:
+ root = _canonical_root(tmp_path, [{"task": "vsi"}])
+ video = next((root / "media/spatial_intelligence/vsi/videos").iterdir())
+ video.write_bytes(b"bad")
+ namespace = evaluate.create_parser().parse_args(
+ [*_canonical_arguments(tmp_path, str(root)), "--run"]
+ )
+
+ with pytest.raises(evaluate.CliError, match="byte-size mismatch"):
+ evaluate.build_launch(namespace)
+
+
+def test_canonical_run_validates_separate_asset_root(tmp_path: Path) -> None:
+ root = _canonical_root(tmp_path, [{"task": "vsi"}])
+ asset_root = tmp_path / "external-assets"
+ asset_root.mkdir()
+ (root / "assets.jsonl").rename(asset_root / "assets.jsonl")
+ (root / "media").rename(asset_root / "media")
+ namespace = evaluate.create_parser().parse_args(
+ [
+ *_canonical_arguments(tmp_path, str(root)),
+ "--asset-root",
+ str(asset_root),
+ "--run",
+ ]
+ )
+
+ _, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert environment["ORARL_EVAL_ASSET_ROOT"] == str(asset_root.resolve())
+
+
+def test_dataset_and_data_root_are_mutually_exclusive(tmp_path: Path) -> None:
+ root = _canonical_root(tmp_path, [{"task": "vsi"}])
+ arguments = _canonical_arguments(tmp_path, str(root))
+ legacy_root = tmp_path / "legacy-root"
+ legacy_root.mkdir()
+ arguments.extend(("--data-root", str(legacy_root)))
+ namespace = evaluate.create_parser().parse_args(arguments)
+
+ with pytest.raises(evaluate.CliError, match="mutually exclusive"):
+ evaluate.build_launch(namespace)
+
+
+def test_asset_root_requires_canonical_dataset(tmp_path: Path) -> None:
+ asset_root = tmp_path / "assets"
+ asset_root.mkdir()
+ namespace = evaluate.create_parser().parse_args(
+ [*_base_arguments(tmp_path), "--asset-root", str(asset_root)]
+ )
+
+ with pytest.raises(evaluate.CliError, match="requires --dataset"):
+ evaluate.build_launch(namespace)
+
+
+def test_split_selection_requires_canonical_dataset(tmp_path: Path) -> None:
+ namespace = evaluate.create_parser().parse_args(
+ [*_base_arguments(tmp_path), "--splits", "test"]
+ )
+
+ with pytest.raises(evaluate.CliError, match="requires --dataset"):
+ evaluate.build_launch(namespace)
+
+
+def test_run_rejects_missing_conventional_inputs(tmp_path: Path) -> None:
+ namespace = evaluate.create_parser().parse_args([*_base_arguments(tmp_path), "--run"])
+ with pytest.raises(evaluate.CliError, match="input does not exist"):
+ evaluate.build_launch(namespace)
+
+
+def test_sam2_run_requires_explicit_postprocessor_files(tmp_path: Path) -> None:
+ arguments = _base_arguments(tmp_path, "segmentation")
+ data_root = tmp_path / "benchmarks"
+ (data_root / "segmentation").mkdir()
+ for name in ("sam2.yaml", "sam2.pt", "postprocess.py"):
+ (data_root / name).touch()
+ config = tmp_path / "segmentation.yaml"
+ config.write_text(
+ "task: segmentation\n"
+ "environment:\n"
+ " SEGMENTATION_SAM2_CFG: sam2.yaml\n"
+ " SEGMENTATION_SAM2_CKPT: sam2.pt\n"
+ " SEGMENTATION_POSTPROCESSOR_PATH: postprocess.py\n",
+ encoding="utf-8",
+ )
+ arguments.extend(
+ (
+ "--task-config",
+ f"segmentation={config}",
+ "--segmentation-run-sam2",
+ "--run",
+ )
+ )
+ namespace = evaluate.create_parser().parse_args(arguments)
+
+ command, environment, _, _, _ = evaluate.build_launch(namespace)
+
+ assert "--segmentation-run-sam2" in command
+ assert environment["SEGMENTATION_POSTPROCESSOR_PATH"] == str(data_root / "postprocess.py")
+
+
+def test_aggregate_embeds_new_official_summary(tmp_path: Path) -> None:
+ results_root = tmp_path / "outputs"
+ task_output = (
+ results_root
+ / "model"
+ / "spatial_intelligence"
+ / "vsi"
+ / "base"
+ / "setting"
+ / "run"
+ )
+ task_output.mkdir(parents=True)
+ (task_output / "summary.json").write_text(
+ json.dumps({"accuracy": 42.0}),
+ encoding="utf-8",
+ )
+ aggregate = tmp_path / "aggregate.json"
+
+ evaluate._write_aggregate(
+ aggregate,
+ model=str(tmp_path / "model"),
+ tasks=["vsi"],
+ results_root=results_root,
+ before={},
+ returncode=0,
+ )
+
+ payload = json.loads(aggregate.read_text(encoding="utf-8"))
+ assert payload["completed_tasks"] == ["vsi"]
+ assert payload["missing_tasks"] == []
+ assert payload["results"][0]["metrics"] == {"accuracy": 42.0}
diff --git a/code/tests/test_cli_metadata.py b/code/tests/test_cli_metadata.py
new file mode 100644
index 0000000000000000000000000000000000000000..97e3e0ecd6d8192fa445b660dc7b49b3bd9b48c9
--- /dev/null
+++ b/code/tests/test_cli_metadata.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import tomllib
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_install_metadata_and_console_scripts() -> None:
+ with (RELEASE_ROOT / "pyproject.toml").open("rb") as handle:
+ metadata = tomllib.load(handle)
+
+ project = metadata["project"]
+ assert project["name"] == "orarl"
+ assert project["requires-python"] == ">=3.10"
+ # The trainer ships inside this distribution, so no external runtime pin.
+ assert not any(dependency.startswith("verl") for dependency in project["dependencies"])
+ assert project["license"] == "Apache-2.0"
+ assert project["license-files"] == ["LICENSE", "NOTICE"]
+ assert project["optional-dependencies"]["hf"] == ["huggingface_hub"]
+ assert project["scripts"] == {
+ "orarl-train": "orarl.cli.train:main",
+ "orarl-eval": "orarl.cli.evaluate:main",
+ "orarl-eval-data": "orarl.cli.eval_data:main",
+ "orarl-prepare": "orarl.cli.prepare:main",
+ }
+ data_files = metadata["tool"]["setuptools"]["data-files"]
+ assert data_files["share/orarl"] == ["environment.yml", "requirements-cu129.txt"]
+ assert data_files["share/orarl/configs"] == ["configs/*.yaml"]
+ assert data_files["share/orarl/docs"] == ["docs/*.md"]
+ assert data_files["share/orarl/scripts"] == ["scripts/*.py", "scripts/*.sh"]
+
+
+def test_install_ships_every_evaluator() -> None:
+ with (RELEASE_ROOT / "pyproject.toml").open("rb") as handle:
+ metadata = tomllib.load(handle)
+
+ installed: set[Path] = set()
+ for target, patterns in metadata["tool"]["setuptools"]["data-files"].items():
+ if not target.startswith("share/orarl/eval"):
+ continue
+ for pattern in patterns:
+ installed.update(RELEASE_ROOT.glob(pattern))
+
+ # Importing an evaluator during the suite drops bytecode next to the source.
+ shipped = {
+ path
+ for path in (RELEASE_ROOT / "eval").rglob("*")
+ if path.is_file() and "__pycache__" not in path.parts
+ }
+
+ assert shipped
+ assert sorted(shipped - installed) == []
+
+
+def test_apache_attribution_files_are_present() -> None:
+ license_text = (RELEASE_ROOT / "LICENSE").read_text(encoding="utf-8")
+ notice_text = (RELEASE_ROOT / "NOTICE").read_text(encoding="utf-8")
+ assert "Apache License" in license_text
+ assert "Version 2.0" in license_text
+ assert "ByteDance Ltd." in notice_text
diff --git a/code/tests/test_cli_train.py b/code/tests/test_cli_train.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6a44d50483261c44bfdd6e3fa78b78a27e196f7
--- /dev/null
+++ b/code/tests/test_cli_train.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+import yaml
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(RELEASE_ROOT))
+
+from orarl.cli import train # noqa: E402
+
+
+def _inputs(tmp_path: Path, method: str = "orarl") -> list[str]:
+ config = tmp_path / "config.yaml"
+ if method == "orarl":
+ config_text = (
+ "data:\n"
+ " rollout_batch_size: 64\n"
+ "algorithm:\n"
+ " name: orarl\n"
+ " selection_prune_ratio: 0.5\n"
+ " selection_positive_quota: 1\n"
+ " selection_negative_quota: 2\n"
+ "worker:\n"
+ " rollout:\n"
+ " n: 8\n"
+ )
+ else:
+ config_text = f"algorithm:\n name: {method}\n"
+ config.write_text(config_text, encoding="utf-8")
+ model = tmp_path / "model"
+ model.mkdir()
+ train_data = tmp_path / "train.jsonl"
+ train_data.write_text("{}\n", encoding="utf-8")
+ val_data = tmp_path / "val.jsonl"
+ val_data.write_text("{}\n", encoding="utf-8")
+ return [
+ "--config",
+ str(config),
+ "--model",
+ str(model),
+ "--train-data",
+ str(train_data),
+ "--val-data",
+ str(val_data),
+ "--output",
+ str(tmp_path / "output"),
+ "--nodes",
+ "2",
+ "--gpus",
+ "4",
+ "--python",
+ sys.executable,
+ ]
+
+
+def test_train_command_uses_full_config_and_explicit_overrides(tmp_path: Path) -> None:
+ parser = train.create_parser()
+ namespace = parser.parse_args(_inputs(tmp_path))
+ command, method = train.build_command(namespace)
+
+ assert method == "orarl"
+ assert namespace.dry_run is True
+ assert command[:3] == [sys.executable, "-m", "verl.trainer.main"]
+ assert f"config={(tmp_path / 'config.yaml').resolve()}" in command
+ assert f"worker.actor.model.model_path={(tmp_path / 'model').resolve()}" in command
+ assert f"data.train_files={(tmp_path / 'train.jsonl').resolve()}" in command
+ assert f"data.val_files={(tmp_path / 'val.jsonl').resolve()}" in command
+ assert f"trainer.save_checkpoint_path={(tmp_path / 'output').resolve()}" in command
+ assert "trainer.nnodes=2" in command
+ assert "trainer.n_gpus_per_node=4" in command
+
+
+def test_train_resolves_bundled_config_name(tmp_path: Path) -> None:
+ arguments = _inputs(tmp_path)
+ arguments[arguments.index("--config") + 1] = "orarl_4b.yaml"
+ namespace = train.create_parser().parse_args(arguments)
+
+ command, method = train.build_command(namespace)
+
+ assert method == "orarl"
+ assert f"config={train.config_path('orarl_4b.yaml')}" in command
+
+
+def test_orarl_dry_run_rejects_incompatible_world_size(tmp_path: Path) -> None:
+ arguments = _inputs(tmp_path)
+ arguments[arguments.index("--nodes") + 1] = "1"
+ arguments[arguments.index("--gpus") + 1] = "6"
+ namespace = train.create_parser().parse_args(arguments)
+
+ with pytest.raises(train.CliError, match="selected batch must divide"):
+ train.build_command(namespace)
+
+
+def test_orarl_dry_run_validates_batch_override(tmp_path: Path) -> None:
+ arguments = [
+ *_inputs(tmp_path),
+ "--set",
+ "data.rollout_batch_size=66",
+ ]
+ namespace = train.create_parser().parse_args(arguments)
+
+ with pytest.raises(train.CliError, match="append-oracle batch must divide"):
+ train.build_command(namespace)
+
+
+def test_train_rejects_missing_required_path(tmp_path: Path) -> None:
+ arguments = _inputs(tmp_path)
+ arguments[arguments.index("--model") + 1] = str(tmp_path / "missing")
+ namespace = train.create_parser().parse_args(arguments)
+ with pytest.raises(train.CliError, match="model does not exist"):
+ train.build_command(namespace)
+
+
+def test_protected_overrides_use_dedicated_options(tmp_path: Path) -> None:
+ arguments = _inputs(tmp_path) + ["--set", "data.train_files=other.jsonl"]
+ namespace = train.create_parser().parse_args(arguments)
+ with pytest.raises(train.CliError, match="dedicated option"):
+ train.build_command(namespace)
+
+
+def test_public_configs_expose_final_recipe() -> None:
+ grpo_4b = yaml.safe_load((RELEASE_ROOT / "configs" / "grpo_4b.yaml").read_text())
+ grpo_9b = yaml.safe_load((RELEASE_ROOT / "configs" / "grpo_9b.yaml").read_text())
+ recipe_4b = yaml.safe_load((RELEASE_ROOT / "configs" / "orarl_4b.yaml").read_text())
+ recipe_9b = yaml.safe_load((RELEASE_ROOT / "configs" / "orarl_9b.yaml").read_text())
+
+ for config in (grpo_4b, grpo_9b):
+ assert config["algorithm"]["name"] == "grpo"
+ assert config["data"]["group_by_task"] is True
+ assert config["data"]["rollout_batch_size"] == ("${oc.env:ORARL_ROLLOUT_BATCH_SIZE,64}")
+ assert config["worker"]["actor"]["global_batch_size"] == (
+ "${oc.env:ORARL_GLOBAL_BATCH_SIZE,64}"
+ )
+ assert config["worker"]["rollout"]["n"] == 8
+ assert config["trainer"]["logger"] == ["console"]
+ assert grpo_9b["worker"]["actor"]["micro_batch_size_per_device_for_update"] == (
+ "${oc.env:ORARL_UPDATE_MICRO_BATCH,1}"
+ )
+ assert grpo_9b["worker"]["actor"]["optim"]["lr"] == ("${oc.env:ORARL_LEARNING_RATE,1.0e-6}")
+ for config in (recipe_4b, recipe_9b):
+ algorithm = config["algorithm"]
+ assert algorithm["name"] == "orarl"
+ assert algorithm["oracle_injection_mode"] == "append"
+ assert algorithm["scale_rewards"] is False
+ assert algorithm["directional_gain"] is True
+ assert algorithm["directional_gain_gamma"] == 0.25
+ assert algorithm["directional_gain_positive_only"] is True
+ assert algorithm["directional_gain_recenter"] is True
+ assert algorithm["detached_oracle_advantage_scale"] == 2.0
+ assert algorithm["detached_oracle_use_directional_gain"] is False
+ assert algorithm["detached_oracle_match_best_ratio"] == 1.2
+ assert algorithm["detached_oracle_match_best_min"] == 0.05
+ assert algorithm["detached_oracle_match_best_max"] == 1.0
+ assert algorithm["oracle_reward_gate_beta"] == 2.0
+ assert algorithm["selection_prune_ratio"] == 0.5
+ assert algorithm["selection_positive_quota"] == 1
+ assert algorithm["selection_negative_quota"] == 2
+ assert algorithm["selection_strict_sign_balance"] is True
+ assert algorithm["post_selection_recenter"] is True
+ assert algorithm["post_selection_rms_match"] is True
+ assert algorithm["post_selection_rms_min_scale"] == 0.25
+ assert algorithm["disable_kl"] is True
+ assert config["data"]["group_by_task"] is True
+ assert config["data"]["rollout_batch_size"] == ("${oc.env:ORARL_ROLLOUT_BATCH_SIZE,64}")
+ assert config["worker"]["actor"]["global_batch_size"] == (
+ "${oc.env:ORARL_GLOBAL_BATCH_SIZE,64}"
+ )
+ assert config["worker"]["rollout"]["n"] == 8
+ assert config["trainer"]["logger"] == ["console"]
diff --git a/code/tests/test_data_build.py b/code/tests/test_data_build.py
new file mode 100644
index 0000000000000000000000000000000000000000..9894adcb0b810b64a118358e9451ecd3a1d4a38e
--- /dev/null
+++ b/code/tests/test_data_build.py
@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+import yaml
+
+ORARL_ROOT = Path(__file__).resolve().parents[1]
+if str(ORARL_ROOT) not in sys.path:
+ sys.path.insert(0, str(ORARL_ROOT))
+
+from orarl.data.build import ConfigError, ShortfallError, build_dataset # noqa: E402
+
+
+def _write_jsonl(path: Path, rows: list[dict[str, object]]) -> None:
+ with path.open("w", encoding="utf-8") as handle:
+ for row in rows:
+ handle.write(json.dumps(row, sort_keys=True) + "\n")
+
+
+def _fixture_config(tmp_path: Path, quota: int = 2, canary_size: int = 1) -> Path:
+ media = tmp_path / "media"
+ media.mkdir()
+ for name in ("a.mp4", "b.mp4", "c.mp4", "benchmark.mp4"):
+ (media / name).write_bytes(name.encode("utf-8"))
+
+ _write_jsonl(
+ tmp_path / "source.jsonl",
+ [
+ {"problem": "Prompt one", "answer": "A", "videos": ["a.mp4"]},
+ {"problem": " PROMPT ONE ", "answer": "A2", "videos": ["./a.mp4"]},
+ {"problem": "Prompt two", "answer": "B", "videos": ["b.mp4"]},
+ {"problem": "Prompt three", "answer": "C", "videos": ["c.mp4"]},
+ {
+ "problem": "Training prompt on benchmark media",
+ "answer": "D",
+ "videos": ["benchmark.mp4"],
+ },
+ ],
+ )
+ _write_jsonl(
+ tmp_path / "benchmarks.jsonl",
+ [
+ {
+ "problem": "Different benchmark prompt",
+ "path": str(media / "benchmark.mp4"),
+ "problem_type": "video_qa_mc",
+ }
+ ],
+ )
+ config = {
+ "seed": 17,
+ "target": quota,
+ "canary_size": canary_size,
+ "max_prompts_per_media": 1,
+ "require_media": True,
+ "benchmark_excludes": ["benchmarks.jsonl"],
+ "sources": [
+ {
+ "name": "video_source",
+ "input": "source.jsonl",
+ "task": "video_qa_mc",
+ "family": "video_qa",
+ "quota": quota,
+ "media_root": "media",
+ "license": "test-only",
+ }
+ ],
+ }
+ config_path = tmp_path / f"config-{quota}-{canary_size}.json"
+ config_path.write_text(json.dumps(config), encoding="utf-8")
+ return config_path
+
+
+class DataBuildTests(unittest.TestCase):
+ def test_example_manifest_matches_materialized_paper_mixture(self) -> None:
+ payload = yaml.safe_load(
+ (ORARL_ROOT / "configs" / "data_sources.example.yaml").read_text(encoding="utf-8")
+ )
+ quotas = {source["name"]: source["quota"] for source in payload["sources"]}
+
+ self.assertEqual(payload["target"], 100_032)
+ self.assertEqual(
+ quotas,
+ {
+ "temporal_grounding_train": 20_096,
+ "tracking_train": 13_952,
+ "segmentation_train": 12_032,
+ "spatial_grounding_train": 7_040,
+ "spatial_temporal_grounding_train": 9_536,
+ "video_qa_train": 20_288,
+ "spatial_intelligence_train": 17_088,
+ },
+ )
+ self.assertEqual(sum(quotas.values()), payload["target"])
+
+ def test_build_is_deterministic_and_audited(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ tmp_path = Path(raw_tmp)
+ config = _fixture_config(tmp_path)
+ first_train = tmp_path / "first.jsonl"
+ first_canary = tmp_path / "first.canary.jsonl"
+ first_manifest_path = tmp_path / "first.manifest.json"
+ second_train = tmp_path / "second.jsonl"
+ second_canary = tmp_path / "second.canary.jsonl"
+
+ manifest = build_dataset(
+ config,
+ first_train,
+ canary_output=first_canary,
+ manifest_output=first_manifest_path,
+ )
+ build_dataset(
+ config,
+ second_train,
+ canary_output=second_canary,
+ manifest_output=tmp_path / "second.manifest.json",
+ )
+
+ self.assertEqual(first_train.read_bytes(), second_train.read_bytes())
+ self.assertEqual(first_canary.read_bytes(), second_canary.read_bytes())
+ self.assertEqual(manifest["actual_train_rows"], 2)
+ self.assertEqual(manifest["actual_canary_rows"], 1)
+ self.assertEqual(manifest["counts"]["train"]["by_task"], {"video_qa_mc": 2})
+ self.assertEqual(manifest["counts"]["train"]["by_source"], {"video_source": 2})
+ self.assertEqual(manifest["duplicates"]["candidate_prompt_rows"], 1)
+ self.assertEqual(manifest["leakage"]["candidate_rows_excluded"], 1)
+ self.assertTrue(all(value == 0 for value in manifest["leakage"]["output"].values()))
+ self.assertEqual(
+ manifest["checksums"]["train"],
+ hashlib.sha256(first_train.read_bytes()).hexdigest(),
+ )
+ self.assertEqual(
+ manifest["checksums"]["canary"],
+ hashlib.sha256(first_canary.read_bytes()).hexdigest(),
+ )
+
+ def test_short_quota_fails_before_writing(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ tmp_path = Path(raw_tmp)
+ config = _fixture_config(tmp_path, quota=4, canary_size=0)
+ train = tmp_path / "short.jsonl"
+
+ with self.assertRaisesRegex(ShortfallError, "video_source short by 1"):
+ build_dataset(config, train)
+
+ self.assertFalse(train.exists())
+ self.assertFalse((tmp_path / "short.canary.jsonl").exists())
+ self.assertFalse((tmp_path / "short.manifest.json").exists())
+
+ def test_short_quota_can_be_explicitly_allowed(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ tmp_path = Path(raw_tmp)
+ config = _fixture_config(tmp_path, quota=4, canary_size=0)
+ train = tmp_path / "allowed.jsonl"
+
+ manifest = build_dataset(config, train, allow_shortfall=True)
+
+ self.assertEqual(manifest["actual_train_rows"], 3)
+ self.assertEqual(
+ manifest["selection"]["source_shortfalls"],
+ {"video_source": 1},
+ )
+
+ def test_preserves_declared_scoring_subtypes_when_requested(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ tmp_path = Path(raw_tmp)
+ media = tmp_path / "media"
+ media.mkdir()
+ for name in ("one.mp4", "two.mp4"):
+ (media / name).write_bytes(b"media")
+ _write_jsonl(
+ tmp_path / "spatial.jsonl",
+ [
+ {
+ "problem": "How many objects?",
+ "answer": "2",
+ "videos": ["one.mp4"],
+ "problem_type": "object_counting",
+ },
+ {
+ "problem": "Which direction?",
+ "answer": "A",
+ "videos": ["two.mp4"],
+ "problem_type": "object_rel_direction",
+ },
+ ],
+ )
+ config = {
+ "target": 2,
+ "canary_size": 0,
+ "require_media": True,
+ "sources": [
+ {
+ "name": "spatial_intelligence",
+ "input": "spatial.jsonl",
+ "task": "spatial intelligence",
+ "family": "spatial_intelligence",
+ "preserve_problem_type": True,
+ "quota": 2,
+ "media_root": "media",
+ }
+ ],
+ }
+ config_path = tmp_path / "config.json"
+ config_path.write_text(json.dumps(config), encoding="utf-8")
+ output = tmp_path / "train.jsonl"
+
+ build_dataset(config_path, output)
+
+ rows = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
+ self.assertEqual(
+ {row["problem_type"] for row in rows},
+ {"object_counting", "object_rel_direction"},
+ )
+
+ def test_output_cannot_replace_an_input(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ tmp_path = Path(raw_tmp)
+ config = _fixture_config(tmp_path)
+ with self.assertRaisesRegex(ConfigError, "cannot replace"):
+ build_dataset(
+ config,
+ tmp_path / "source.jsonl",
+ overwrite=True,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/code/tests/test_data_schema.py b/code/tests/test_data_schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6217b648cf97f7ce1fac0224c4d540dfe069959
--- /dev/null
+++ b/code/tests/test_data_schema.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+ORARL_ROOT = Path(__file__).resolve().parents[1]
+if str(ORARL_ROOT) not in sys.path:
+ sys.path.insert(0, str(ORARL_ROOT))
+
+from orarl.data import ( # noqa: E402
+ SchemaError,
+ canonicalize_record,
+ prompt_identity,
+ validate_record,
+)
+
+
+class DataSchemaTests(unittest.TestCase):
+ def test_canonicalizes_aliases_and_relative_media(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ tmp_path = Path(raw_tmp)
+ media_root = tmp_path / "media"
+ image = media_root / "frames" / "one.jpg"
+ image.parent.mkdir(parents=True)
+ image.write_bytes(b"image")
+
+ record = canonicalize_record(
+ {
+ "question": " Which object is red? ",
+ "ground_truth": {"label": "ball"},
+ "image": "frames/one.jpg",
+ "metadata": {"split": "train"},
+ },
+ problem_type="spatial grounding",
+ source="unit_source",
+ family="spatial",
+ media_root=media_root,
+ require_media=True,
+ )
+
+ self.assertEqual(record["problem"], " Which object is red? ")
+ self.assertEqual(record["answer"], {"label": "ball"})
+ self.assertEqual(record["images"], [str(image)])
+ self.assertEqual(record["videos"], [])
+ self.assertEqual(record["problem_type"], "spatial grounding")
+ self.assertEqual(record["source"], "unit_source")
+ self.assertEqual(record["metadata"], {"split": "train"})
+
+ def test_rejects_empty_oracle_labels(self) -> None:
+ answers = [None, "", " ", [], {}, {"label": ""}, float("nan")]
+ for answer in answers:
+ with self.subTest(answer=answer):
+ record = {
+ "problem": "Question",
+ "answer": answer,
+ "images": ["image.jpg"],
+ "videos": [],
+ "problem_type": "spatial grounding",
+ "source": "unit_source",
+ }
+ with self.assertRaisesRegex(SchemaError, "oracle label"):
+ validate_record(record)
+
+ def test_media_existence_is_switchable(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ record = {
+ "problem": "Question",
+ "answer": "Answer",
+ "images": [str(Path(raw_tmp) / "missing.jpg")],
+ "videos": [],
+ "problem_type": "spatial grounding",
+ "source": "unit_source",
+ }
+ validate_record(record, require_media=False)
+ with self.assertRaisesRegex(SchemaError, "does not exist"):
+ validate_record(record, require_media=True)
+
+ def test_prompt_identity_normalizes_text_and_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ tmp_path = Path(raw_tmp)
+ first = {
+ "problem": " Which OBJECT is red?",
+ "answer": "ball",
+ "images": [str(tmp_path / "frames" / ".." / "one.jpg")],
+ "videos": [],
+ "problem_type": "Spatial Grounding",
+ "source": "one",
+ }
+ second = {
+ **first,
+ "problem": "which object is red?",
+ "images": [str(tmp_path / "one.jpg")],
+ "source": "two",
+ }
+ self.assertEqual(prompt_identity(first), prompt_identity(second))
+
+ def test_rejects_remote_media_reference(self) -> None:
+ with self.assertRaisesRegex(SchemaError, "remote media"):
+ canonicalize_record(
+ {
+ "problem": "Question",
+ "answer": "Answer",
+ "video": "https://example.invalid/clip.mp4",
+ },
+ problem_type="video_qa_mc",
+ source="unit_source",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/code/tests/test_environment_spec.py b/code/tests/test_environment_spec.py
new file mode 100644
index 0000000000000000000000000000000000000000..57b357eccd014d39f0972a62bca91b1fb00d3b46
--- /dev/null
+++ b/code/tests/test_environment_spec.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import yaml
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_h20_environment_uses_the_pinned_cuda_stack() -> None:
+ environment = yaml.safe_load((RELEASE_ROOT / "environment.yml").read_text(encoding="utf-8"))
+ dependencies = set(environment["dependencies"])
+
+ assert environment["name"] == "orarl"
+ assert "python=3.11" in dependencies
+ assert "cuda-toolkit=12.9" in dependencies
+ assert "ffmpeg=7" in dependencies
+ assert "conda-forge" in environment["channels"]
+
+ installer = (RELEASE_ROOT / "scripts" / "create_conda_env.sh").read_text(encoding="utf-8")
+ assert "https://download.pytorch.org/whl/cu129" in installer
+ assert "torch==2.10.0+cu129" in installer
+ assert "vllm==0.19.1" in installer
+ assert "flash-attn==2.8.3" in installer
+ assert "install_conda_runtime_hook.sh" in installer
+ assert "--evaluation-only" in installer
+ # A single checkout installs the trainer; no external runtime root remains.
+ assert "--runtime-root" not in installer
+ assert "ORARL_RUNTIME_ROOT" not in installer
+
+ validator = (RELEASE_ROOT / "scripts" / "check_environment.py").read_text(
+ encoding="utf-8"
+ )
+ assert '"verl.trainer.main"' in validator
+ assert "--evaluation-only" in validator
+
+ runtime_hook = (
+ RELEASE_ROOT / "scripts" / "install_conda_runtime_hook.sh"
+ ).read_text(encoding="utf-8")
+ assert "activate.d/orarl-runtime.sh" in runtime_hook
+ assert 'Path(sysconfig.get_path("purelib")) / "nvidia"' in runtime_hook
+
+
+def test_runtime_requirements_match_the_released_qwen35_stack() -> None:
+ requirements = {
+ line
+ for line in (RELEASE_ROOT / "requirements-cu129.txt")
+ .read_text(encoding="utf-8")
+ .splitlines()
+ if line and not line.startswith("#")
+ }
+
+ assert "transformers==5.5.4" in requirements
+ assert "qwen-vl-utils[decord]==0.0.14" in requirements
+ assert "torchcodec==0.10.0" in requirements
+ assert "ray[default]==2.54.0" in requirements
+ assert "flash-linear-attention==0.4.2" in requirements
+
+ evaluator = (
+ RELEASE_ROOT / "eval" / "task" / "temporal_grounding" / "eval_timelens_hf.py"
+ ).read_text(encoding="utf-8")
+ assert 'setdefault("FORCE_QWENVL_VIDEO_READER", "decord")' in evaluator
+
+ segmentation_evaluator = (
+ RELEASE_ROOT / "eval" / "task" / "segmentation" / "eval_seg_vllm.py"
+ ).read_text(encoding="utf-8")
+ assert 'setdefault("FORCE_QWENVL_VIDEO_READER", "decord")' in segmentation_evaluator
diff --git a/code/tests/test_eval_canonical_data.py b/code/tests/test_eval_canonical_data.py
new file mode 100644
index 0000000000000000000000000000000000000000..e74f5142f311e0758ca8e650c82f2e84e0a7d952
--- /dev/null
+++ b/code/tests/test_eval_canonical_data.py
@@ -0,0 +1,403 @@
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+TASK_DIR = RELEASE_ROOT / "eval" / "task"
+sys.path.insert(0, str(TASK_DIR))
+sys.path.insert(0, str(TASK_DIR / "spatial_grounding"))
+sys.path.insert(0, str(TASK_DIR / "tracking"))
+sys.path.insert(0, str(TASK_DIR / "spatial_temporal_grounding"))
+sys.path.insert(0, str(TASK_DIR / "segmentation"))
+
+from _grounding_utils import load_annotations as load_grounding_annotations # noqa: E402
+from canonical_data import ( # noqa: E402
+ CanonicalDataError,
+ adapt_canonical_row,
+ canonical_dataset_record,
+ load_json_records,
+ repository_relative_output_path,
+)
+from eval_stvg_vllm import load_dataset as load_stvg_dataset # noqa: E402
+from eval_tracking_vllm import load_dataset as load_tracking_dataset # noqa: E402
+from eval_vllm import vsi_prompt, vsi_qtype, vsi_score # noqa: E402
+from post_sam2 import normalize_missing_rle_counts, sam2_config_name # noqa: E402
+
+
+def _row() -> dict[str, object]:
+ return {
+ "schema_version": 1,
+ "eval_task": "tracking",
+ "sample_id": "sample-1",
+ "benchmark": "tracking",
+ "split": "got10k",
+ "problem": "Track the object.",
+ "answer": {"boxes": {"1": [1, 2, 3, 4]}},
+ "images": ["media/tracking/images/frame.jpg"],
+ "videos": [{"path": "media/tracking/videos/clip.mp4", "fps": 2}],
+ "subtitles": ["media/tracking/subtitles/clip.srt"],
+ "problem_type": "tracking",
+ "source": "tracking",
+ "choices": ["left", "right"],
+ "preprocessed": {
+ "preprocessed_video": "artifacts/tracking/clip.npz",
+ },
+ "task_payload": {
+ "boxes": {"1": [1, 2, 3, 4]},
+ "mask_path": "artifacts/tracking/mask.json",
+ },
+ "metadata": {"category": "fixture"},
+ }
+
+
+def test_adapter_resolves_paths_and_preserves_canonical_fields(tmp_path: Path) -> None:
+ adapted = adapt_canonical_row(_row(), tmp_path)
+
+ assert adapted["eval_task"] == "tracking"
+ assert adapted["images"] == [
+ str(tmp_path / "media/tracking/images/frame.jpg")
+ ]
+ assert adapted["videos"][0]["path"] == str(
+ tmp_path / "media/tracking/videos/clip.mp4"
+ )
+ assert adapted["subtitles"] == [
+ str(tmp_path / "media/tracking/subtitles/clip.srt")
+ ]
+ assert adapted["task_payload"]["mask_path"] == str(
+ tmp_path / "artifacts/tracking/mask.json"
+ )
+ assert adapted["preprocessed_video"] == str(
+ tmp_path / "artifacts/tracking/clip.npz"
+ )
+ assert adapted["path"] == str(tmp_path / "media/tracking/videos/clip.mp4")
+ assert adapted["video"] == adapted["path"]
+ assert adapted["image"] == str(tmp_path / "media/tracking/images/frame.jpg")
+ assert adapted["options"] == ["left", "right"]
+ assert adapted["ground_truth"] == {"boxes": {"1": [1, 2, 3, 4]}}
+ assert adapted["boxes"] == {"1": [1, 2, 3, 4]}
+ assert adapted["category"] == "fixture"
+
+
+def test_adapter_resolves_media_and_artifacts_from_separate_asset_root(
+ tmp_path: Path,
+) -> None:
+ metadata_root = tmp_path / "metadata"
+ asset_root = tmp_path / "assets"
+
+ adapted = adapt_canonical_row(
+ _row(),
+ metadata_root,
+ asset_root=asset_root,
+ )
+
+ assert adapted["image"] == str(
+ asset_root / "media/tracking/images/frame.jpg"
+ )
+ assert adapted["video"] == str(
+ asset_root / "media/tracking/videos/clip.mp4"
+ )
+ assert adapted["preprocessed_video"] == str(
+ asset_root / "artifacts/tracking/clip.npz"
+ )
+
+
+def test_loader_infers_repository_root_from_manifests(tmp_path: Path) -> None:
+ root = tmp_path / "dataset"
+ annotation = root / "annotations/tracking/got10k.jsonl"
+ annotation.parent.mkdir(parents=True)
+ (root / "datasets.jsonl").write_text("{}\n", encoding="utf-8")
+ annotation.write_text(json.dumps(_row()) + "\n", encoding="utf-8")
+
+ loaded = load_json_records(annotation)
+
+ assert loaded[0]["video"] == str(
+ root / "media/tracking/videos/clip.mp4"
+ )
+
+
+def test_adapter_leaves_legacy_rows_unchanged(tmp_path: Path) -> None:
+ legacy = {"question": "Legacy?", "path": "relative.mp4"}
+
+ assert adapt_canonical_row(legacy, tmp_path) == legacy
+
+
+def test_repository_relative_output_path_supports_sam2_postprocessing(
+ tmp_path: Path,
+) -> None:
+ media = tmp_path / "media/segmentation/videos/mevis/clip.mp4"
+
+ assert repository_relative_output_path(str(media), tmp_path) == (
+ "./media/segmentation/videos/mevis/clip.mp4"
+ )
+ outside = tmp_path.parent / "outside.mp4"
+ assert repository_relative_output_path(str(outside), tmp_path) == str(outside)
+
+
+def test_sam2_absolute_config_becomes_hydra_package_name(tmp_path: Path) -> None:
+ package = tmp_path / "sam2"
+ config = package / "configs/sam2.1/sam2.1_hiera_l.yaml"
+
+ assert sam2_config_name(config.as_posix(), package) == (
+ "configs/sam2.1/sam2.1_hiera_l.yaml"
+ )
+ assert sam2_config_name("configs/sam2.1/sam2.1_hiera_l.yaml", package) == (
+ "configs/sam2.1/sam2.1_hiera_l.yaml"
+ )
+
+
+def test_sam2_normalizes_polygon_and_empty_video_rles() -> None:
+ class CocoMask:
+ @staticmethod
+ def frPyObjects(polygons, height, width):
+ if isinstance(polygons, dict):
+ assert polygons == {"size": [height, width], "counts": [6]}
+ return {"size": [height, width], "counts": b"empty"}
+ assert polygons == [[0, 0, 2, 0, 2, 2]]
+ return [{"size": [height, width], "counts": b"encoded"}]
+
+ @staticmethod
+ def merge(rles):
+ return rles[0]
+
+ payload = {
+ "results": [
+ {
+ "data_type": "image",
+ "segmentation_output": {
+ "segmentation_polygon": [[0, 0, 2, 0, 2, 2]],
+ "segmentation_rle": {"size": [2, 3]},
+ },
+ },
+ {
+ "data_type": "video",
+ "task_payload": {
+ "segmentation_output": {
+ "segmentation_rle": {
+ "00000": {"size": [2, 3]},
+ "00001": {"size": [2, 3], "counts": [6]},
+ "00002": {"size": [2, 3], "counts": "valid"},
+ }
+ }
+ },
+ },
+ ]
+ }
+
+ assert normalize_missing_rle_counts(payload, CocoMask) == 3
+ assert payload["results"][0]["segmentation_output"]["segmentation_rle"] == {
+ "size": [2, 3],
+ "counts": "encoded",
+ }
+ assert payload["results"][1]["segmentation_output"]["segmentation_rle"] == {
+ "00000": {"size": [2, 3], "counts": "empty"},
+ "00001": {"size": [2, 3], "counts": "empty"},
+ "00002": {"size": [2, 3], "counts": "valid"},
+ }
+
+
+def test_vsi_prefers_the_benchmark_subtask_over_generic_answer_type() -> None:
+ record = {
+ "problem": "How many chairs are in this room?",
+ "problem_type": "regression",
+ "question_type": "regression",
+ "original_question_type": "object_counting",
+ }
+
+ assert vsi_qtype(record) == "object_counting"
+ assert "Answer with an integer within " in vsi_prompt(record)
+ assert vsi_score(vsi_qtype(record), "3 ", "3 ") == (
+ "MRA",
+ "3.0",
+ 1.0,
+ )
+
+
+def test_loader_preserves_whitespace_prefixed_legacy_json(tmp_path: Path) -> None:
+ legacy = [{"question": "Legacy?", "path": "relative.mp4"}]
+ data_file = tmp_path / "legacy.json"
+ data_file.write_text(
+ "\n " + json.dumps(legacy, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+ assert load_json_records(data_file) == legacy
+
+
+def test_adapter_rejects_repository_escape(tmp_path: Path) -> None:
+ row = _row()
+ row["videos"] = ["../outside.mp4"]
+
+ with pytest.raises(CanonicalDataError, match="outside"):
+ adapt_canonical_row(row, tmp_path)
+
+
+def test_adapter_rejects_absolute_path_outside_repository(tmp_path: Path) -> None:
+ row = _row()
+ row["videos"] = [str(tmp_path.parent / "outside.mp4")]
+
+ with pytest.raises(CanonicalDataError, match="outside"):
+ adapt_canonical_row(row, tmp_path)
+
+
+def test_dataset_profile_resolves_snake_case_split_alias(tmp_path: Path) -> None:
+ root = tmp_path / "dataset"
+ root.mkdir()
+ (root / "datasets.jsonl").write_text(
+ json.dumps(
+ {
+ "task": "temporal_grounding",
+ "split": "charades_timelens",
+ "preprocessing": {"fps": 4},
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ record = canonical_dataset_record(
+ "temporal_grounding",
+ "charades-timelens",
+ root,
+ )
+
+ assert record is not None
+ assert record["preprocessing"]["fps"] == 4
+
+
+def test_spatial_grounding_loads_canonical_split_with_legacy_name(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ root = tmp_path / "metadata"
+ asset_root = tmp_path / "assets"
+ annotation = root / "annotations/spatial_grounding/refcoco_val.jsonl"
+ image = asset_root / "media/spatial_grounding/images/image.jpg"
+ annotation.parent.mkdir(parents=True)
+ image.parent.mkdir(parents=True)
+ image.write_bytes(b"fixture")
+ row = {
+ "schema_version": 1,
+ "eval_task": "spatial_grounding",
+ "sample_id": "refcoco-val-1",
+ "benchmark": "spatial_grounding",
+ "split": "refcoco_val",
+ "problem": "the red box",
+ "answer": [16, 47, 469, 935],
+ "images": ["media/spatial_grounding/images/image.jpg"],
+ "videos": [],
+ "problem_type": "spatial_grounding",
+ "source": "refcoco",
+ "family": "spatial_grounding",
+ "task_payload": {"normalized_solution": [16, 47, 469, 935]},
+ "metadata": {"height": 428, "width": 640},
+ "evaluation": {},
+ }
+ annotation.write_text(json.dumps(row) + "\n", encoding="utf-8")
+ (root / "datasets.jsonl").write_text("{}\n", encoding="utf-8")
+ monkeypatch.setenv("ORARL_EVAL_DATA_ROOT", str(root))
+ monkeypatch.setenv("ORARL_EVAL_ASSET_ROOT", str(asset_root))
+
+ records = load_grounding_annotations(
+ str(annotation.parent),
+ "refcoco-val",
+ )
+
+ assert records == [
+ {
+ "problem_id": "refcoco-val-1",
+ "image_path": str(image),
+ "expression": "the red box",
+ "bbox": [16.0, 47.0, 469.0, 935.0],
+ "width": 640,
+ "height": 428,
+ }
+ ]
+
+
+def test_tracking_loads_canonical_split_with_legacy_name(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ root = tmp_path / "metadata"
+ asset_root = tmp_path / "assets"
+ annotation = root / "annotations/tracking/got10k.jsonl"
+ video = asset_root / "media/tracking/videos/clip.mp4"
+ annotation.parent.mkdir(parents=True)
+ video.parent.mkdir(parents=True)
+ video.write_bytes(b"fixture")
+ answer = '{"boxes":{"1":[1,2,3,4]}} '
+ row = {
+ "schema_version": 1,
+ "eval_task": "tracking",
+ "sample_id": "got10k-1",
+ "benchmark": "tracking",
+ "split": "got10k",
+ "problem": "Track the object.",
+ "answer": answer,
+ "images": [],
+ "videos": ["media/tracking/videos/clip.mp4"],
+ "problem_type": "tracking",
+ "source": "got10k",
+ "family": "tracking",
+ "task_payload": {"boxes": {"1": [1, 2, 3, 4]}},
+ "evaluation": {},
+ }
+ annotation.write_text(json.dumps(row) + "\n", encoding="utf-8")
+ (root / "datasets.jsonl").write_text("{}\n", encoding="utf-8")
+ monkeypatch.setenv("ORARL_EVAL_DATA_ROOT", str(root))
+ monkeypatch.setenv("ORARL_EVAL_ASSET_ROOT", str(asset_root))
+
+ records = load_tracking_dataset(str(annotation.parent), "eval_got10k")
+
+ assert records[0]["problem"] == "Track the object."
+ assert records[0]["solution"] == answer
+ assert records[0]["path"] == str(video)
+
+
+def test_stvg_loads_canonical_split_with_legacy_name(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ root = tmp_path / "metadata"
+ asset_root = tmp_path / "assets"
+ annotation = root / "annotations/stvg/stvg.jsonl"
+ video = asset_root / "media/stvg/videos/stvg/clip.mp4"
+ annotation.parent.mkdir(parents=True)
+ video.parent.mkdir(parents=True)
+ video.write_bytes(b"fixture")
+ answer = (
+ '{"time":[1,2],"boxes":{"1":[1,2,3,4],'
+ '"2":[2,3,4,5]}} '
+ )
+ row = {
+ "schema_version": 1,
+ "eval_task": "stvg",
+ "sample_id": "stvg-1",
+ "benchmark": "stvg",
+ "split": "stvg",
+ "problem": "When and where is the person moving?",
+ "answer": answer,
+ "images": [],
+ "videos": ["media/stvg/videos/stvg/clip.mp4"],
+ "problem_type": "spatial-temporal grounding",
+ "source": "stvg",
+ "family": "spatial_temporal_grounding",
+ "task_payload": {
+ "time": [1, 2],
+ "boxes": {"1": [1, 2, 3, 4], "2": [2, 3, 4, 5]},
+ },
+ "evaluation": {},
+ }
+ annotation.write_text(json.dumps(row) + "\n", encoding="utf-8")
+ (root / "datasets.jsonl").write_text("{}\n", encoding="utf-8")
+ monkeypatch.setenv("ORARL_EVAL_DATA_ROOT", str(root))
+ monkeypatch.setenv("ORARL_EVAL_ASSET_ROOT", str(asset_root))
+
+ records = load_stvg_dataset(str(annotation.parent), "eval_stvg")
+
+ assert records[0]["solution"] == answer
+ assert records[0]["path"] == str(video)
diff --git a/code/tests/test_eval_source_discovery.py b/code/tests/test_eval_source_discovery.py
new file mode 100644
index 0000000000000000000000000000000000000000..16de0a56b73e565dd8623a0e237fc038451d3a4c
--- /dev/null
+++ b/code/tests/test_eval_source_discovery.py
@@ -0,0 +1,319 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+SCRIPT_PATH = RELEASE_ROOT / "scripts" / "create_eval_source_manifest.py"
+SPEC = importlib.util.spec_from_file_location("orarl_eval_source_discovery", SCRIPT_PATH)
+assert SPEC is not None and SPEC.loader is not None
+DISCOVERY = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = DISCOVERY
+SPEC.loader.exec_module(DISCOVERY)
+
+
+def _write(path: Path, content: str = "[]\n") -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _fixture_roots(tmp_path: Path) -> tuple[Path, Path]:
+ data_root = tmp_path / "licensed-data"
+ runtime_root = tmp_path / "runtime"
+ valid_data = runtime_root / "eval" / "data" / "valid_data"
+ valid_data.mkdir(parents=True)
+
+ for folder in (
+ "Video-MME",
+ "Video-MME-v2",
+ "MVBench",
+ "MMVU",
+ "Video-Holmes",
+ "LongVideoBench",
+ "MLVU",
+ "VSI-Bench",
+ "MMSI-Bench",
+ "MindCube-Tiny",
+ "ReVSI",
+ "Spatial-Grounding",
+ "OneThinker-eval",
+ "TimeLens-Bench",
+ "preprocessed_videos",
+ "qvhighlights-videos",
+ ):
+ (data_root / folder).mkdir(parents=True)
+ (data_root / "Video-MME" / "data").mkdir()
+ (data_root / "Video-MME" / "preprocessed_videos_384f_262k_total0").mkdir()
+ (data_root / "Video-MME-v2" / "preprocessed_videos_384f_262k_total0").mkdir()
+ (data_root / "MVBench" / "videos").mkdir()
+ (data_root / "OneThinker-eval" / "Refcoco").mkdir()
+ (data_root / "TimeLens-Bench" / "video_shards" / "charades").mkdir(parents=True)
+ (data_root / "TimeLens-Bench" / "video_shards" / "activitynet").mkdir()
+ (data_root / "TimeLens-Bench" / "video_shards" / "qvhighlights").mkdir()
+
+ for name in (
+ "videomme_preprocessed_384f_262k_total0.jsonl",
+ "videommev2_preprocessed_384f_262k_total0.jsonl",
+ "mvbench.json",
+ "mmvu_mc.jsonl",
+ "videoholmes.jsonl",
+ "longvideobench_val.jsonl",
+ "mlvu_mc.jsonl",
+ "vsibench_preprocessed_128f_16M.jsonl",
+ ):
+ _write(valid_data / name, "{}\n" if name.endswith(".jsonl") else "[]\n")
+
+ _write(data_root / "MMSI-Bench" / "MMSI_bench.tsv", "id\tquestion\tanswer\timage\n")
+ _write(data_root / "MindCube-Tiny" / "combined-00000-of-00001.parquet", "")
+ _write(data_root / "ReVSI" / "all_frame" / "test-00000-of-00001.parquet", "")
+ for name in (
+ "refcoco_val.json",
+ "refcoco_testA.json",
+ "refcoco_testB.json",
+ "refcocop_val.json",
+ "refcocop_testA.json",
+ "refcocop_testB.json",
+ "refcocog_val.json",
+ "refcocog_test.json",
+ ):
+ _write(
+ runtime_root
+ / "eval"
+ / "task"
+ / "spatial_grounding"
+ / "rec_jsons_processed"
+ / name
+ )
+ for name in (
+ "eval_got10k.json",
+ "eval_stvg.json",
+ "eval_seg_refcoco.json",
+ "eval_seg_refcocop.json",
+ "eval_seg_refcocog.json",
+ "eval_seg_mevis.json",
+ "eval_seg_reasonvos.json",
+ ):
+ _write(data_root / "OneThinker-eval" / name)
+ for name in (
+ "charades-timelens.json",
+ "activitynet-timelens.json",
+ "qvhighlights-timelens.json",
+ ):
+ _write(data_root / "TimeLens-Bench" / name, "{}\n")
+
+ for excluded in ("VideoMMMU", "LVBench", "outputs", "training"):
+ directory = data_root / excluded
+ directory.mkdir()
+ _write(directory / "annotations.jsonl", "{}\n")
+ return data_root, runtime_root
+
+
+def test_discovery_covers_only_paper_tasks_and_defaults_to_unauthorized(
+ tmp_path: Path,
+) -> None:
+ data_root, runtime_root = _fixture_roots(tmp_path)
+
+ records = DISCOVERY.discover_eval_sources(data_root, runtime_root)
+
+ assert {record["eval_task"] for record in records} == set(DISCOVERY.PAPER_TASKS)
+ assert len({record["eval_task"] for record in records}) == 16
+ assert all(record["benchmark"] == record["eval_task"] for record in records)
+ assert all(record["redistribution_authorized"] is False for record in records)
+ assert all(str(record["split"]) == str(record["split"]).casefold() for record in records)
+ serialized = "\n".join(json.dumps(record, sort_keys=True) for record in records)
+ for excluded in ("VideoMMMU", "LVBench", "outputs", "training"):
+ assert excluded not in serialized
+ temporal = [
+ record for record in records if record["eval_task"] == "temporal_grounding"
+ ]
+ assert {record["split"] for record in temporal} == {
+ "activitynet_timelens",
+ "charades_timelens",
+ "qvhighlights_timelens",
+ }
+ qvhighlights = next(
+ record for record in temporal if record["split"] == "qvhighlights_timelens"
+ )
+ assert qvhighlights["media_roots"]["videos"].endswith(
+ "TimeLens-Bench/video_shards/qvhighlights"
+ )
+ charades = next(record for record in temporal if record["split"] == "charades_timelens")
+ assert charades["preprocessing"]["fps"] == 4
+ for record in temporal:
+ assert record["preprocessing"] == {
+ "fps": 4,
+ "min_tokens": 1,
+ "max_frames": 2048,
+ "max_pixels": 409600,
+ "total_tokens": 128000,
+ }
+ assert record["legacy_environment"]["TIMELENS_NUM_WORKERS"] == 2
+ grounding = [
+ record for record in records if record["eval_task"] == "spatial_grounding"
+ ]
+ assert all(
+ "/eval/task/spatial_grounding/rec_jsons_processed/"
+ in record["annotation_input"]
+ for record in grounding
+ )
+ assert {
+ record["split"] for record in grounding
+ } == {
+ "refcoco_val",
+ "refcoco_test_a",
+ "refcoco_test_b",
+ "refcocop_val",
+ "refcocop_test_a",
+ "refcocop_test_b",
+ "refcocog_val",
+ "refcocog_test",
+ }
+ assert {
+ record["legacy_environment"]["SPATIAL_GROUNDING_DATASETS"]
+ for record in grounding
+ } == {
+ (
+ "refcoco-val,refcoco-testA,refcoco-testB,"
+ "refcoco+-val,refcoco+-testA,refcoco+-testB,"
+ "refcocog-val,refcocog-test"
+ )
+ }
+ assert all(
+ record["media_roots"]["images"].endswith("OneThinker-eval/Refcoco")
+ for record in grounding
+ )
+ videomme = next(record for record in records if record["eval_task"] == "videomme")
+ assert videomme["media_roots"]["videos"].endswith("Video-MME/data")
+ mvbench = next(record for record in records if record["eval_task"] == "mvbench")
+ assert mvbench["media_roots"]["videos"].endswith("licensed-data/MVBench")
+ mindcube = next(record for record in records if record["eval_task"] == "mindcube")
+ assert mindcube["evaluation"] == {
+ "prompt_profile": "mindcube_official",
+ "parser_profile": "multiple_choice",
+ "metric_profile": "micro_accuracy",
+ "aggregation": "micro",
+ "expected_group_counts": {
+ "rotation": 200,
+ "among": 600,
+ "around": 250,
+ },
+ }
+ assert mindcube["source_url"].endswith(
+ "tree/7dd2725d9bd4149f2aad00a9843f72a3824da003"
+ )
+ revsi = next(record for record in records if record["eval_task"] == "revsi")
+ assert revsi["annotation_input"].endswith(
+ "ReVSI/all_frame/test-00000-of-00001.parquet"
+ )
+ assert revsi["media_roots"]["videos"].endswith("ReVSI/all_frame")
+ assert revsi["evaluation"]["frame_protocol"] == "native_all_frame"
+ assert revsi["legacy_environment"]["REVSI_SETTING"].startswith("native-all-f128")
+ assert revsi["legacy_environment"]["REVSI_EXACT_NFRAMES"] is True
+ assert revsi["legacy_environment"]["REVSI_EXPECTED_SAMPLES"] == 6808
+ tracking = next(
+ record for record in records if record["eval_task"] == "tracking"
+ )
+ assert tracking["split"] == "got10k"
+ assert tracking["legacy_environment"]["TRACKING_DATASETS"] == "eval_got10k"
+ assert tracking["media_roots"]["default"].endswith("OneThinker-eval")
+ stvg = next(record for record in records if record["eval_task"] == "stvg")
+ assert stvg["split"] == "stvg"
+ assert stvg["legacy_environment"]["STVG_DATASETS"] == "eval_stvg"
+ assert stvg["media_roots"]["default"].endswith("OneThinker-eval")
+ segmentation = [
+ record for record in records if record["eval_task"] == "segmentation"
+ ]
+ assert {record["split"] for record in segmentation} == {
+ "refcoco",
+ "refcocop",
+ "refcocog",
+ "mevis",
+ "reasonvos",
+ }
+ assert {
+ record["split"]: record["expected_count"] for record in segmentation
+ } == {
+ "mevis": 424,
+ "reasonvos": 458,
+ "refcoco": 3811,
+ "refcocog": 2537,
+ "refcocop": 3805,
+ }
+ assert all(
+ record["legacy_environment"]["SEGMENTATION_BATCH_SIZE"] == 16
+ and record["legacy_environment"]["SEGMENTATION_MAX_PIXELS_IMAGE"] == 1048576
+ and record["legacy_environment"]["SEGMENTATION_VIDEO_READER"] == "decord"
+ and record["legacy_environment"]["SEGMENTATION_RUN_SAM2"] is False
+ and record["preprocessing"]["video_reader"] == "decord"
+ for record in segmentation
+ )
+
+
+def test_authorization_requires_the_explicit_confirmation_flag(tmp_path: Path) -> None:
+ data_root, runtime_root = _fixture_roots(tmp_path)
+ output = tmp_path / "private" / "sources.jsonl"
+
+ assert (
+ DISCOVERY.main(
+ [
+ "--data-root",
+ str(data_root),
+ "--runtime-root",
+ str(runtime_root),
+ "--output",
+ str(output),
+ "--confirm-redistribution-authorized",
+ ]
+ )
+ == 0
+ )
+ records = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines() if line]
+ assert records
+ assert all(record["redistribution_authorized"] is True for record in records)
+
+
+def test_discovery_fails_on_ambiguous_required_annotation(tmp_path: Path) -> None:
+ data_root, runtime_root = _fixture_roots(tmp_path)
+ _write(data_root / "MMSI-Bench" / "duplicate" / "MMSI_bench.tsv", "id\n")
+
+ with pytest.raises(DISCOVERY.DiscoveryError, match="ambiguous mmsi annotation"):
+ DISCOVERY.discover_eval_sources(data_root, runtime_root)
+
+
+def test_tsv_count_accepts_large_embedded_image_fields(tmp_path: Path) -> None:
+ annotation = _write(
+ tmp_path / "mmsi.tsv",
+ f"id\timage\nsample-1\t{'a' * 200_000}\n",
+ )
+
+ assert DISCOVERY._cheap_expected_count(annotation) == 1
+
+
+def test_mindcube_discovery_accepts_one_verified_official_parquet(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ old_subset = _write(tmp_path / "data" / "test.parquet", "")
+ official = _write(tmp_path / "official.parquet", "")
+ counts = {old_subset: 120, official: 1050}
+ monkeypatch.setattr(DISCOVERY, "_cheap_expected_count", counts.__getitem__)
+
+ assert DISCOVERY._mindcube_annotation(tmp_path) == official.resolve()
+
+
+def test_mindcube_discovery_rejects_only_reduced_parquet(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ reduced = _write(tmp_path / "data" / "test.parquet", "")
+ monkeypatch.setattr(DISCOVERY, "_cheap_expected_count", lambda _path: 120)
+
+ with pytest.raises(DISCOVERY.DiscoveryError, match="requires 1050 rows"):
+ DISCOVERY._mindcube_annotation(tmp_path)
+
+ assert reduced.is_file()
diff --git a/code/tests/test_evaluation_manifest.py b/code/tests/test_evaluation_manifest.py
new file mode 100644
index 0000000000000000000000000000000000000000..ee786dfb2c237a344c942a04d06ffafc89e8caf2
--- /dev/null
+++ b/code/tests/test_evaluation_manifest.py
@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import sys
+import tempfile
+import unittest
+from copy import deepcopy
+from pathlib import Path
+
+ORARL_ROOT = Path(__file__).resolve().parents[1]
+if str(ORARL_ROOT) not in sys.path:
+ sys.path.insert(0, str(ORARL_ROOT))
+
+from orarl.evaluation import ( # noqa: E402
+ EVALUATION_SCHEMA_VERSION,
+ MANIFEST_FILENAME,
+ ManifestError,
+ load_dataset_manifest,
+ validate_dataset_manifest,
+ validate_dataset_manifest_record,
+ validate_evaluation_repository,
+)
+
+
+def _manifest(expected_count: int = 1) -> dict[str, object]:
+ return {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "benchmark": "videomme",
+ "split": "test",
+ "task": "videomme",
+ "family": "video_qa",
+ "annotation_path": "annotations/video_qa/videomme/test.jsonl",
+ "media_paths": [
+ "media/video_qa/videomme/videos",
+ "media/video_qa/videomme/subtitles",
+ ],
+ "artifact_paths": [],
+ "expected_count": expected_count,
+ "license": "CC-BY-4.0",
+ "source_url": "https://example.org/videomme",
+ "redistribution_authorized": True,
+ "evaluation": {
+ "prompt_profile": "video_qa",
+ "parser_profile": "multiple_choice",
+ "metric_profile": "accuracy",
+ },
+ "legacy_environment": {
+ "VIDEOMME_DATA_FILE": "annotations/video_qa/videomme/test.jsonl",
+ "VIDEOMME_VIDEO_ROOT": "media/video_qa/videomme/videos",
+ "VIDEOMME_EXPECTED_SAMPLES": 1,
+ },
+ }
+
+
+def _evaluation_row() -> dict[str, object]:
+ return {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "eval_task": "videomme",
+ "sample_id": "q-1",
+ "benchmark": "videomme",
+ "split": "test",
+ "problem": "What happens next?",
+ "answer": "B",
+ "images": [],
+ "videos": ["media/video_qa/videomme/videos/clip.mp4"],
+ "problem_type": "video_qa_mc",
+ "source": "Video-MME",
+ "family": "video_qa",
+ "subtitles": ["media/video_qa/videomme/subtitles/clip.srt"],
+ }
+
+
+def _write_jsonl(path: Path, records: list[dict[str, object]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", encoding="utf-8") as handle:
+ for record in records:
+ handle.write(json.dumps(record, sort_keys=True) + "\n")
+
+
+class EvaluationManifestTests(unittest.TestCase):
+ def test_accepts_complete_manifest_record(self) -> None:
+ record = _manifest()
+ self.assertIs(validate_dataset_manifest_record(record), record)
+
+ def test_rejects_duplicate_datasets_and_noncanonical_layout(self) -> None:
+ with self.assertRaisesRegex(ManifestError, "duplicate dataset"):
+ validate_dataset_manifest([_manifest(), deepcopy(_manifest())])
+
+ record = _manifest()
+ record["annotation_path"] = "annotations/VideoMME/test.jsonl"
+ with self.assertRaisesRegex(ManifestError, "annotation_path must be"):
+ validate_dataset_manifest_record(record)
+
+ def test_rejects_unsafe_legacy_paths_and_credentialed_source_urls(self) -> None:
+ record = _manifest()
+ record["source_url"] = "https://" + "user:" + "secret@" + "example.org/videomme"
+ record["legacy_environment"] = {"VIDEOMME_DATA_FILE": "/tmp/test.jsonl"}
+ record["preprocessing"] = {"artifact_path": "https://example.org/cache.pt"}
+ with self.assertRaises(ManifestError) as raised:
+ validate_dataset_manifest_record(record)
+ self.assertIn("without embedded credentials", str(raised.exception))
+ self.assertIn("repository-relative", str(raised.exception))
+ self.assertIn("absolute path or URL", str(raised.exception))
+
+ def test_loads_and_validates_a_complete_repository(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ root = Path(raw_tmp)
+ video = root / "media/video_qa/videomme/videos/clip.mp4"
+ subtitle = root / "media/video_qa/videomme/subtitles/clip.srt"
+ video.parent.mkdir(parents=True)
+ subtitle.parent.mkdir(parents=True)
+ video.write_bytes(b"video")
+ subtitle.write_bytes(b"subtitle")
+
+ annotation = root / "annotations/video_qa/videomme/test.jsonl"
+ _write_jsonl(annotation, [_evaluation_row()])
+ record = _manifest()
+ record["checksums"] = {
+ "annotations/video_qa/videomme/test.jsonl": hashlib.sha256(
+ annotation.read_bytes()
+ ).hexdigest(),
+ "media/video_qa/videomme/videos/clip.mp4": hashlib.sha256(
+ video.read_bytes()
+ ).hexdigest(),
+ }
+ manifest_path = root / MANIFEST_FILENAME
+ _write_jsonl(manifest_path, [record])
+
+ loaded = load_dataset_manifest(root)
+ self.assertEqual(loaded[0]["benchmark"], "videomme")
+ datasets = validate_evaluation_repository(root)
+ self.assertEqual(len(datasets[("videomme", "test")]), 1)
+
+ record["expected_count"] = 2
+ _write_jsonl(manifest_path, [record])
+ with self.assertRaisesRegex(ManifestError, "expected 2 rows"):
+ validate_evaluation_repository(root)
+
+ def test_checksum_and_redistribution_checks_are_switchable(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ root = Path(raw_tmp)
+ video = root / "media/video_qa/videomme/videos/clip.mp4"
+ subtitle = root / "media/video_qa/videomme/subtitles/clip.srt"
+ video.parent.mkdir(parents=True)
+ subtitle.parent.mkdir(parents=True)
+ video.write_bytes(b"video")
+ subtitle.write_bytes(b"subtitle")
+ _write_jsonl(
+ root / "annotations/video_qa/videomme/test.jsonl",
+ [_evaluation_row()],
+ )
+
+ record = _manifest()
+ record["redistribution_authorized"] = False
+ record["checksums"] = {
+ "media/video_qa/videomme/videos/clip.mp4": "0" * 64
+ }
+ _write_jsonl(root / MANIFEST_FILENAME, [record])
+
+ load_dataset_manifest(root)
+ with self.assertRaisesRegex(ManifestError, "checksum mismatch"):
+ load_dataset_manifest(root, repository_root=root)
+
+ record.pop("checksums")
+ _write_jsonl(root / MANIFEST_FILENAME, [record])
+ with self.assertRaisesRegex(ManifestError, "redistribution is not authorized"):
+ validate_evaluation_repository(root)
+ validate_evaluation_repository(
+ root,
+ require_redistribution_authorized=False,
+ )
+
+ def test_manifest_filename_is_fixed(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ path = Path(raw_tmp) / "manifest.jsonl"
+ _write_jsonl(path, [_manifest()])
+ with self.assertRaisesRegex(ManifestError, MANIFEST_FILENAME):
+ load_dataset_manifest(path)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/code/tests/test_evaluation_release.py b/code/tests/test_evaluation_release.py
new file mode 100644
index 0000000000000000000000000000000000000000..1bc0e2eb546ffcb0fc48f7314cfbaebefd0ccc9c
--- /dev/null
+++ b/code/tests/test_evaluation_release.py
@@ -0,0 +1,459 @@
+from __future__ import annotations
+
+import json
+import sys
+import types
+from pathlib import Path
+
+import pytest
+import yaml
+
+import orarl.evaluation.staging as evaluation_staging
+from orarl.cli import eval_data
+from orarl.evaluation import (
+ StagingError,
+ UploadError,
+ build_evaluation_repository,
+ dataset_card_metadata,
+ dataset_card_subsets,
+ export_evaluation_index,
+ export_public_evaluation_repository,
+ inventory_evaluation_sources,
+ upload_evaluation_repository,
+)
+
+
+def _write_jsonl(path: Path, records: list[dict[str, object]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ "".join(
+ json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n" for record in records
+ ),
+ encoding="utf-8",
+ )
+
+
+def _source_fixture(
+ tmp_path: Path,
+ *,
+ expected_count: int | None = 1,
+ with_artifact: bool = False,
+) -> Path:
+ video_root = tmp_path / "source-videos"
+ video_root.mkdir()
+ (video_root / "clip.mp4").write_bytes(b"release-video")
+ artifact_root = tmp_path / "source-artifacts"
+ if with_artifact:
+ artifact_root.mkdir()
+ (artifact_root / "clip.npz").write_bytes(b"processed-video")
+ annotation = tmp_path / "annotations.jsonl"
+ row: dict[str, object] = {
+ "id": "sample-1",
+ "question": "What happens?",
+ "answer": "A",
+ "video": "clip.mp4",
+ }
+ if with_artifact:
+ row["preprocessed_video"] = "clip.npz"
+ _write_jsonl(
+ annotation,
+ [row],
+ )
+ manifest = tmp_path / "private-sources.jsonl"
+ source: dict[str, object] = {
+ "benchmark": "videomme",
+ "eval_task": "videomme",
+ "split": "test",
+ "family": "video_qa",
+ "adapter": "generic",
+ "annotation_input": str(annotation),
+ "video_root": str(video_root),
+ "expected_count": expected_count,
+ "license": "fixture-only",
+ "source_url": "https://example.org/videomme",
+ "redistribution_authorized": True,
+ "evaluation": {
+ "prompt_profile": "fixture",
+ "parser_profile": "multiple_choice",
+ "metric_profile": "accuracy",
+ },
+ }
+ if with_artifact:
+ source["preprocessed_root"] = str(artifact_root)
+ _write_jsonl(
+ manifest,
+ [source],
+ )
+ return manifest
+
+
+def _card_header(path: Path) -> dict[str, object]:
+ text = path.read_text(encoding="utf-8")
+ assert text.startswith("---\n")
+ _prefix, header, _body = text.split("---\n", 2)
+ value = yaml.safe_load(header)
+ assert isinstance(value, dict)
+ return value
+
+
+def test_build_generates_deterministic_hf_card_and_attributes(tmp_path: Path) -> None:
+ manifest = _source_fixture(tmp_path)
+ first = tmp_path / "first"
+ second = tmp_path / "second"
+
+ build_evaluation_repository(manifest, first)
+ build_evaluation_repository(manifest, second)
+
+ assert (first / "README.md").read_bytes() == (second / "README.md").read_bytes()
+ assert (first / ".gitattributes").read_bytes() == (second / ".gitattributes").read_bytes()
+ metadata = _card_header(first / "README.md")
+ assert metadata["configs"] == [
+ {
+ "config_name": "videomme",
+ "data_files": [
+ {
+ "split": "test",
+ "path": "annotations/video_qa/videomme/test.jsonl",
+ }
+ ],
+ }
+ ]
+ assert metadata["orarl"]["totals"]["row_count"] == 1
+ assert metadata["orarl"]["totals"]["asset_count"] == 2
+ assert metadata["orarl"]["benchmarks"][0]["task"] == "videomme"
+ assert metadata["orarl"]["benchmarks"][0]["family"] == "video_qa"
+ assert metadata["size_categories"] == ["n<1K"]
+ assert "evaluation" in metadata["tags"]
+ card = (first / "README.md").read_text(encoding="utf-8")
+ assert "# OraRL-Eval" in card
+ assert "> **Evaluation only.**" in card
+ assert "## Benchmark configs and provenance" in card
+ assert "## Canonical row schema" in card
+ assert "## Intended use and limitations" in card
+ assert "arXiv preprint arXiv:2608.20492" in card
+ assert "load_dataset(" in card
+ assert "orarl-eval \\" in card
+ assert "--dataset OraRL/OraRL-Eval" in card
+ attributes = (first / ".gitattributes").read_text(encoding="utf-8")
+ assert "annotations/video_qa/videomme/test.jsonl filter=lfs" not in attributes
+ assert "media/video_qa/videomme/videos/** filter=lfs" in attributes
+
+
+def test_metadata_cli_refreshes_and_validates_existing_repository(tmp_path: Path) -> None:
+ root = tmp_path / "repository"
+ build_evaluation_repository(_source_fixture(tmp_path), root)
+ expected_readme = (root / "README.md").read_bytes()
+ (root / "README.md").write_text("stale card\n", encoding="utf-8")
+
+ assert eval_data.main(["metadata", "--root", str(root)]) == 0
+
+ assert (root / "README.md").read_bytes() == expected_readme
+
+
+def test_export_index_keeps_only_portable_jsonl_metadata(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ complete = tmp_path / "complete"
+ index = tmp_path / "index"
+ fast_index = tmp_path / "fast-index"
+ build_evaluation_repository(_source_fixture(tmp_path), complete)
+
+ summary = export_evaluation_index(complete, index)
+
+ def _fail_hash(_path: Path) -> str:
+ pytest.fail("checksum-free index export must not calculate SHA-256")
+
+ monkeypatch.setattr(evaluation_staging, "sha256_file", _fail_hash)
+ fast_summary = export_evaluation_index(
+ complete,
+ fast_index,
+ checksums=False,
+ )
+
+ assert summary["annotation_assets"] == 1
+ assert summary["external_assets"] == 1
+ assert summary["asset_checksums"] is False
+ assert fast_summary["asset_checksums"] is False
+ assert (fast_index / "datasets.jsonl").read_bytes() == (
+ index / "datasets.jsonl"
+ ).read_bytes()
+ assert (index / "datasets.jsonl").is_file()
+ assert not (index / "assets.jsonl").exists()
+ assert (index / "annotations/video_qa/videomme/test.jsonl").is_file()
+ assert not (index / "media").exists()
+ assert not (index / "artifacts").exists()
+ index_card = (index / "README.md").read_text(encoding="utf-8")
+ assert "pretty_name: OraRL-Data" in index_card
+ assert "No media is redistributed" in index_card
+ assert "--asset-root" in index_card
+ assert index.stat().st_mode & 0o777 == 0o755
+ assert (index / "datasets.jsonl").stat().st_mode & 0o777 == 0o644
+ with pytest.raises(FileExistsError, match="refusing to overwrite"):
+ export_evaluation_index(complete, index)
+
+
+def test_export_public_keeps_raw_media_and_removes_processed_artifacts(
+ tmp_path: Path,
+) -> None:
+ complete = tmp_path / "complete"
+ public = tmp_path / "public"
+ build_evaluation_repository(
+ _source_fixture(tmp_path, with_artifact=True),
+ complete,
+ )
+ generated_frame = (
+ complete
+ / "media/video_qa/videomme/videos/test/sam2-cache/000000.jpg"
+ )
+ generated_frame.parent.mkdir(parents=True)
+ generated_frame.write_bytes(b"unmanifested-generated-frame")
+
+ summary = export_public_evaluation_repository(complete, public)
+
+ assert summary["excluded_artifacts"] == 1
+ assert (public / "media/video_qa/videomme/videos/test/clip.mp4").is_file()
+ assert not (public / generated_frame.relative_to(complete)).exists()
+ assert not (public / "artifacts").exists()
+ assets = [
+ json.loads(line)
+ for line in (public / "assets.jsonl").read_text(encoding="utf-8").splitlines()
+ ]
+ assert all(record["kind"] != "artifacts" for record in assets)
+ datasets = [
+ json.loads(line)
+ for line in (public / "datasets.jsonl").read_text(encoding="utf-8").splitlines()
+ ]
+ assert datasets[0]["artifact_paths"] == []
+ rows = [
+ json.loads(line)
+ for line in (
+ public / "annotations/video_qa/videomme/test.jsonl"
+ ).read_text(encoding="utf-8").splitlines()
+ ]
+ assert "preprocessed" not in rows[0]
+ card = (public / "README.md").read_text(encoding="utf-8")
+ assert "--dataset ./OraRL-Data/OraRL-eval-data" in card
+ assert "# OraRL-Data" in card
+ assert "referenced raw media" in card
+ assert "Preprocessed tensors" in card
+ assert "No media is redistributed" not in card
+ assert "path: OraRL-eval-data/annotations/video_qa/videomme/test.jsonl" in card
+ assert "OraRL-eval-data/media/video_qa/videomme/videos/**" in (
+ public / ".gitattributes"
+ ).read_text(encoding="utf-8")
+
+ (public / "README.md").write_text("stale card\n", encoding="utf-8")
+ assert (
+ eval_data.main(
+ [
+ "metadata",
+ "--root",
+ str(public),
+ "--repo-id",
+ "OraRL/OraRL-Data",
+ ]
+ )
+ == 0
+ )
+ assert "referenced raw media" in (public / "README.md").read_text(
+ encoding="utf-8"
+ )
+
+
+def test_card_subset_and_metadata_apis_group_splits() -> None:
+ records = [
+ {
+ "schema_version": 1,
+ "benchmark": "spatial_grounding",
+ "split": split,
+ "task": "spatial_grounding",
+ "family": "spatial_grounding",
+ "annotation_path": f"annotations/spatial_grounding/{split}.jsonl",
+ "media_paths": ["media/spatial_grounding/images"],
+ "artifact_paths": [],
+ "expected_count": count,
+ "license": "fixture-only",
+ "source_url": "https://example.org/spatial",
+ "redistribution_authorized": True,
+ "evaluation": {"metric_profile": "iou"},
+ }
+ for split, count in (("refcoco_val", 2), ("refcocog_test", 3))
+ ]
+ assets = [
+ {
+ "path": f"annotations/spatial_grounding/{split}.jsonl",
+ "bytes": count * 10,
+ "kind": "annotations",
+ "benchmark": "spatial_grounding",
+ }
+ for split, count in (("refcoco_val", 2), ("refcocog_test", 3))
+ ]
+
+ assert dataset_card_subsets(records) == [
+ {
+ "config_name": "spatial_grounding",
+ "data_files": [
+ {
+ "split": "refcoco_val",
+ "path": "annotations/spatial_grounding/refcoco_val.jsonl",
+ },
+ {
+ "split": "refcocog_test",
+ "path": "annotations/spatial_grounding/refcocog_test.jsonl",
+ },
+ ],
+ }
+ ]
+ metadata = dataset_card_metadata(records, assets)
+ assert metadata["orarl"]["totals"] == {
+ "benchmark_count": 1,
+ "split_count": 2,
+ "row_count": 5,
+ "asset_count": 2,
+ "byte_count": 50,
+ }
+ assert metadata["size_categories"] == ["n<1K"]
+ assert metadata["tags"][:3] == ["video", "multimodal", "evaluation"]
+ fields = {item["name"]: item for item in metadata["orarl"]["schema_fields"]}
+ assert fields["sample_id"]["required"] is True
+ assert fields["task_payload"]["type"] == "object"
+
+
+def test_inventory_can_lock_null_expected_counts_for_build(tmp_path: Path) -> None:
+ manifest = _source_fixture(tmp_path, expected_count=None)
+ inventory = inventory_evaluation_sources(manifest)
+
+ assert inventory["sources"][0]["expected_count"] is None
+ assert inventory["sources"][0]["count_matches"] is None
+ assert inventory["totals"]["unlocked_sources"] == 1
+ with pytest.raises(StagingError, match="expected_count is not locked"):
+ build_evaluation_repository(manifest, tmp_path / "unlocked-output")
+
+ locked = tmp_path / "locked-sources.jsonl"
+ assert (
+ eval_data.main(
+ [
+ "inventory",
+ "--manifest",
+ str(manifest),
+ "--write-locked-manifest",
+ str(locked),
+ "--task",
+ "videomme",
+ ]
+ )
+ == 0
+ )
+ locked_record = json.loads(locked.read_text(encoding="utf-8"))
+ assert locked_record["expected_count"] == 1
+ assert Path(locked_record["annotation_input"]).is_absolute()
+ build_evaluation_repository(locked, tmp_path / "locked-output")
+ with pytest.raises(StagingError, match="no records for task"):
+ inventory_evaluation_sources(manifest, tasks=["temporal_grounding"])
+
+
+def test_upload_validates_then_passes_only_noncredential_arguments(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ root = tmp_path / "repository"
+ build_evaluation_repository(_source_fixture(tmp_path), root)
+ calls: list[dict[str, object]] = []
+ constructors: list[tuple[object, ...]] = []
+ module = types.ModuleType("huggingface_hub")
+
+ class FakeApi:
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ constructors.append((*args, kwargs))
+
+ def upload_large_folder(self, **kwargs: object) -> None:
+ calls.append(dict(kwargs))
+
+ module.HfApi = FakeApi # type: ignore[attr-defined]
+ monkeypatch.setitem(sys.modules, "huggingface_hub", module)
+ monkeypatch.setenv("HF_TOKEN", "environment-login-only")
+
+ result = upload_evaluation_repository(
+ root,
+ "OraRL/OraRL-Eval",
+ revision="release",
+ private=True,
+ num_workers=7,
+ )
+
+ assert constructors == [({},)]
+ assert calls == [
+ {
+ "repo_id": "OraRL/OraRL-Eval",
+ "folder_path": str(root.resolve()),
+ "repo_type": "dataset",
+ "revision": "release",
+ "private": True,
+ "num_workers": 7,
+ }
+ ]
+ assert "token" not in calls[0]
+ assert result["rows"] == 1
+
+
+def test_upload_failure_does_not_echo_authentication_details(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ root = tmp_path / "repository"
+ build_evaluation_repository(_source_fixture(tmp_path), root)
+ detail = "sensitive-auth-detail"
+ module = types.ModuleType("huggingface_hub")
+
+ class FakeApi:
+ def upload_large_folder(self, **_kwargs: object) -> None:
+ raise RuntimeError(f"authorization failed for {detail}")
+
+ module.HfApi = FakeApi # type: ignore[attr-defined]
+ monkeypatch.setitem(sys.modules, "huggingface_hub", module)
+
+ with pytest.raises(UploadError) as captured:
+ upload_evaluation_repository(root, "OraRL/OraRL-Eval")
+ assert detail not in str(captured.value)
+
+
+def test_upload_never_calls_hf_when_full_validation_fails(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ root = tmp_path / "repository"
+ build_evaluation_repository(_source_fixture(tmp_path), root)
+ video = next(
+ path
+ for path in (root / "media/video_qa/videomme/videos").rglob("*")
+ if path.is_file()
+ )
+ video.write_bytes(b"tampered")
+ module = types.ModuleType("huggingface_hub")
+
+ class ForbiddenApi:
+ def __init__(self) -> None:
+ raise AssertionError("network client must not be constructed")
+
+ module.HfApi = ForbiddenApi # type: ignore[attr-defined]
+ monkeypatch.setitem(sys.modules, "huggingface_hub", module)
+
+ with pytest.raises(StagingError, match="mismatch"):
+ upload_evaluation_repository(root, "OraRL/OraRL-Eval")
+
+
+def test_upload_cli_has_no_token_argument() -> None:
+ parser = eval_data.create_parser()
+ with pytest.raises(SystemExit):
+ parser.parse_args(
+ [
+ "upload",
+ "--root",
+ "/tmp/repository",
+ "--repo-id",
+ "org/repo",
+ "--token",
+ "forbidden",
+ ]
+ )
diff --git a/code/tests/test_evaluation_runtime.py b/code/tests/test_evaluation_runtime.py
new file mode 100644
index 0000000000000000000000000000000000000000..2c276149f4fb1571a20f2541a09bb92e8c7c8faa
--- /dev/null
+++ b/code/tests/test_evaluation_runtime.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(RELEASE_ROOT))
+
+from orarl.cli import evaluate # noqa: E402
+
+EVAL_TASK_ROOT = RELEASE_ROOT / "eval" / "task"
+
+# One entry point per paper task family, so a release cannot drop a family.
+FAMILY_ENTRY_POINTS = {
+ "video_qa": "eval_vllm.py",
+ "spatial_intelligence": "revsi/eval_revsi_vllm.py",
+ "temporal_grounding": "temporal_grounding/eval_timelens_hf.py",
+ "spatial_grounding": "spatial_grounding/eval_refcoco_vllm.py",
+ "tracking": "tracking/eval_tracking_vllm.py",
+ "spatial_temporal_grounding": "spatial_temporal_grounding/eval_stvg_vllm.py",
+ "segmentation": "segmentation/eval_seg_vllm.py",
+}
+
+
+def test_every_task_family_ships_an_entry_point() -> None:
+ missing = sorted(
+ family
+ for family, relative in FAMILY_ENTRY_POINTS.items()
+ if not (EVAL_TASK_ROOT / relative).is_file()
+ )
+
+ assert missing == []
+ assert (EVAL_TASK_ROOT / "eval.sh").is_file()
+ assert (EVAL_TASK_ROOT / "mmsi" / "eval_mmsi_transformers.py").is_file()
+ assert (EVAL_TASK_ROOT / "mindcube" / "data_utils.py").is_file()
+
+
+def test_evaluator_is_discovered_without_external_runtime(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ monkeypatch.delenv("ORARL_EVALUATOR", raising=False)
+ monkeypatch.delenv("ORARL_RUNTIME_ROOT", raising=False)
+ monkeypatch.setattr(evaluate, "find_spec", lambda _: None)
+ monkeypatch.chdir(tmp_path)
+
+ assert evaluate._discover_evaluator(None) == (EVAL_TASK_ROOT / "eval.sh").resolve()
+
+
+@pytest.mark.parametrize(
+ "relative",
+ sorted(path.relative_to(EVAL_TASK_ROOT).as_posix() for path in EVAL_TASK_ROOT.rglob("*.sh")),
+)
+def test_shipped_shell_evaluators_parse(relative: str) -> None:
+ result = subprocess.run(
+ ["bash", "-n", str(EVAL_TASK_ROOT / relative)],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr
+
+
+def test_segmentation_loads_the_shipped_decord_patch() -> None:
+ patch = EVAL_TASK_ROOT / "qwenvl_decord_patch.py"
+ evaluator = (EVAL_TASK_ROOT / "segmentation" / "eval_seg_vllm.py").read_text(encoding="utf-8")
+
+ assert patch.is_file()
+ assert "import qwenvl_decord_patch" in evaluator
+ assert "Path(__file__).resolve().parents[1]," in evaluator
diff --git a/code/tests/test_evaluation_schema.py b/code/tests/test_evaluation_schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..04c54afeba6c133cc100717c21c06887dc2faa44
--- /dev/null
+++ b/code/tests/test_evaluation_schema.py
@@ -0,0 +1,222 @@
+from __future__ import annotations
+
+import hashlib
+import sys
+import tempfile
+import unittest
+from copy import deepcopy
+from pathlib import Path
+
+ORARL_ROOT = Path(__file__).resolve().parents[1]
+if str(ORARL_ROOT) not in sys.path:
+ sys.path.insert(0, str(ORARL_ROOT))
+
+from orarl.evaluation import ( # noqa: E402
+ EVALUATION_SCHEMA_VERSION,
+ EvaluationSchemaError,
+ annotation_path,
+ artifact_directory,
+ media_directory,
+ validate_dataset_id,
+ validate_evaluation_row,
+ validate_evaluation_rows,
+ validate_repository_path,
+)
+
+
+def _row(
+ sample_id: str = "sample-001",
+ video: str = "media/video_qa/videomme/videos/clip.mp4",
+):
+ return {
+ "schema_version": EVALUATION_SCHEMA_VERSION,
+ "eval_task": "videomme",
+ "sample_id": sample_id,
+ "benchmark": "videomme",
+ "split": "test",
+ "problem": "What happens next?",
+ "answer": "B",
+ "images": [],
+ "videos": [video],
+ "problem_type": "video_qa_mc",
+ "source": "Video-MME",
+ "family": "video_qa",
+ "choices": ["A. Nothing", "B. A person enters"],
+ "subtitles": ["media/video_qa/videomme/subtitles/clip.srt"],
+ "preprocessed": {
+ "video_path": "artifacts/video_qa/videomme/preprocessed/clip.npz",
+ "settings": {"frames": 128},
+ },
+ "task_payload": {"duration": 12.5},
+ "metadata": {"question_id": "q-1"},
+ "evaluation": {"group": "short_video"},
+ }
+
+
+class EvaluationSchemaTests(unittest.TestCase):
+ def test_accepts_training_core_and_eval_envelope(self) -> None:
+ row = _row()
+
+ self.assertIs(validate_evaluation_row(row), row)
+ self.assertEqual(
+ annotation_path("videomme", "test"),
+ "annotations/video_qa/videomme/test.jsonl",
+ )
+
+ def test_accepts_mask_backed_segmentation_without_text_answer(self) -> None:
+ row = _row(video="")
+ row.update(
+ {
+ "eval_task": "segmentation",
+ "benchmark": "segmentation",
+ "split": "mevis",
+ "answer": None,
+ "images": [],
+ "videos": ["media/segmentation/videos/mevis/clip.mp4"],
+ "problem_type": "segmentation",
+ "source": "mevis",
+ "family": "segmentation",
+ "task_payload": {
+ "segmentation_output": {
+ "frames": ["00000"],
+ "segmentation_rle": {
+ "00000": {"size": [2, 2], "counts": "13"}
+ },
+ }
+ },
+ }
+ )
+ row.pop("subtitles")
+ row.pop("preprocessed")
+
+ self.assertIs(validate_evaluation_row(row), row)
+ row["task_payload"] = {}
+ with self.assertRaisesRegex(
+ EvaluationSchemaError,
+ "answer must contain a nonempty oracle label",
+ ):
+ validate_evaluation_row(row)
+
+ def test_video_qa_benchmarks_share_one_physical_family(self) -> None:
+ for benchmark in (
+ "videomme",
+ "videommev2",
+ "mvbench",
+ "mmvu",
+ "videoholmes",
+ "longvideobench",
+ "mlvu",
+ ):
+ with self.subTest(benchmark=benchmark):
+ self.assertEqual(
+ annotation_path(benchmark, "test"),
+ f"annotations/video_qa/{benchmark}/test.jsonl",
+ )
+ self.assertEqual(
+ media_directory(benchmark, "videos"),
+ f"media/video_qa/{benchmark}/videos",
+ )
+ self.assertEqual(
+ artifact_directory(benchmark),
+ f"artifacts/video_qa/{benchmark}",
+ )
+
+ self.assertEqual(
+ annotation_path("spatial_grounding", "refcoco_val"),
+ "annotations/spatial_grounding/refcoco_val.jsonl",
+ )
+
+ def test_spatial_intelligence_benchmarks_share_one_physical_family(self) -> None:
+ for benchmark in ("vsi", "mmsi", "mindcube", "revsi"):
+ with self.subTest(benchmark=benchmark):
+ self.assertEqual(
+ annotation_path(benchmark, "test"),
+ f"annotations/spatial_intelligence/{benchmark}/test.jsonl",
+ )
+ self.assertEqual(
+ media_directory(benchmark, "videos"),
+ f"media/spatial_intelligence/{benchmark}/videos",
+ )
+ self.assertEqual(
+ artifact_directory(benchmark),
+ f"artifacts/spatial_intelligence/{benchmark}",
+ )
+
+ def test_repository_paths_are_strict_posix_relative_paths(self) -> None:
+ invalid = (
+ "/tmp/clip.mp4",
+ "https://example.invalid/clip.mp4",
+ r"media\videomme\videos\clip.mp4",
+ "media/videomme/videos/../clip.mp4",
+ "media/videomme/videos/./clip.mp4",
+ )
+ for path in invalid:
+ with self.subTest(path=path):
+ with self.assertRaises(ValueError):
+ validate_repository_path(path)
+
+ for path in invalid:
+ with self.subTest(row_path=path):
+ row = _row(video=path)
+ with self.assertRaises(EvaluationSchemaError):
+ validate_evaluation_row(row)
+
+ def test_requires_snake_case_dataset_identifiers_and_canonical_scope(self) -> None:
+ for identifier in ("VideoMME", "video-mme", "video__mme", "_videomme"):
+ with self.subTest(identifier=identifier):
+ with self.assertRaises(ValueError):
+ validate_dataset_id(identifier)
+
+ row = _row(video="media/other_benchmark/videos/clip.mp4")
+ with self.assertRaisesRegex(
+ EvaluationSchemaError,
+ "media/video_qa/videomme/videos",
+ ):
+ validate_evaluation_row(row)
+
+ def test_rejects_duplicate_rows_and_asset_case_collisions(self) -> None:
+ with self.assertRaisesRegex(EvaluationSchemaError, "duplicate sample_id"):
+ validate_evaluation_rows([_row(), deepcopy(_row())])
+
+ second = _row(
+ sample_id="sample-002",
+ video="media/video_qa/videomme/videos/Clip.mp4",
+ )
+ with self.assertRaisesRegex(EvaluationSchemaError, "case collision"):
+ validate_evaluation_rows([_row(), second])
+
+ def test_can_verify_asset_existence_and_checksums(self) -> None:
+ with tempfile.TemporaryDirectory() as raw_tmp:
+ root = Path(raw_tmp)
+ assets = {
+ "media/video_qa/videomme/videos/clip.mp4": b"video",
+ "media/video_qa/videomme/subtitles/clip.srt": b"subtitle",
+ "artifacts/video_qa/videomme/preprocessed/clip.npz": b"artifact",
+ }
+ for relative, content in assets.items():
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(content)
+
+ video_path = "media/video_qa/videomme/videos/clip.mp4"
+ checksum = hashlib.sha256(assets[video_path]).hexdigest()
+ validate_evaluation_row(
+ _row(),
+ repository_root=root,
+ checksums={video_path: checksum},
+ )
+
+ with self.assertRaisesRegex(EvaluationSchemaError, "checksum mismatch"):
+ validate_evaluation_row(
+ _row(),
+ repository_root=root,
+ checksums={video_path: "0" * 64},
+ )
+
+ (root / video_path).unlink()
+ with self.assertRaisesRegex(EvaluationSchemaError, "does not exist"):
+ validate_evaluation_row(_row(), repository_root=root)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/code/tests/test_evaluation_staging.py b/code/tests/test_evaluation_staging.py
new file mode 100644
index 0000000000000000000000000000000000000000..a683b83ee250e831934f57d4cf853a1267888e3b
--- /dev/null
+++ b/code/tests/test_evaluation_staging.py
@@ -0,0 +1,1100 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import os
+from pathlib import Path
+
+import pytest
+
+import orarl.evaluation.converters as evaluation_converters
+import orarl.evaluation.layout as evaluation_layout
+import orarl.evaluation.staging as evaluation_staging
+from orarl.cli import eval_data
+from orarl.evaluation import (
+ StagingError,
+ build_evaluation_repository,
+ inventory_evaluation_sources,
+ load_asset_manifest,
+ load_evaluation_jsonl,
+ load_source_manifest,
+ merge_evaluation_repository,
+ validate_staged_repository,
+)
+
+
+def _write_jsonl(path: Path, records: list[dict[str, object]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", encoding="utf-8") as handle:
+ for record in records:
+ handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
+
+
+def _source_record(
+ annotation: Path,
+ *,
+ benchmark: str,
+ eval_task: str,
+ family: str,
+ adapter: str = "generic",
+ expected_count: int = 1,
+ **roots: Path,
+) -> dict[str, object]:
+ record: dict[str, object] = {
+ "benchmark": benchmark,
+ "eval_task": eval_task,
+ "split": "test",
+ "family": family,
+ "adapter": adapter,
+ "annotation_input": str(annotation),
+ "expected_count": expected_count,
+ "license": "fixture-only",
+ "source_url": f"https://example.org/{benchmark}",
+ "redistribution_authorized": True,
+ "evaluation": {
+ "prompt_profile": "fixture",
+ "parser_profile": "fixture",
+ "metric_profile": "fixture",
+ },
+ "preprocessing": {"fps": 2, "max_frames": 32},
+ }
+ record.update({key: str(value) for key, value in roots.items()})
+ return record
+
+
+def _write_manifest(path: Path, records: list[dict[str, object]]) -> Path:
+ _write_jsonl(path, records)
+ return path
+
+
+def _generic_fixture(tmp_path: Path) -> tuple[Path, Path, bytes]:
+ video_root = tmp_path / "source-videos"
+ subtitle_root = tmp_path / "source-subtitles"
+ preprocessed_root = tmp_path / "source-artifacts"
+ video_root.mkdir()
+ subtitle_root.mkdir()
+ preprocessed_root.mkdir()
+ video_bytes = b"fixture-video"
+ (video_root / "clip.mp4").write_bytes(video_bytes)
+ (subtitle_root / "clip.srt").write_text("fixture subtitle\n", encoding="utf-8")
+ (preprocessed_root / "clip.npz").write_bytes(b"fixture-array")
+
+ annotation = tmp_path / "video-qa.json"
+ annotation.write_text(
+ json.dumps(
+ [
+ {
+ "question_id": "q-1",
+ "question": "What happens?",
+ "answer": "B",
+ "options": ["A. Nothing", "B. Motion"],
+ "path": "clip.mp4",
+ "image": "",
+ "video": "",
+ "subtitles": "",
+ "subtitle_path": "clip.srt",
+ "artifact_path": "",
+ "preprocessed_video": "clip.npz",
+ "preprocessed_video_settings": {"frames": 32},
+ "question_category": "motion",
+ }
+ ]
+ ),
+ encoding="utf-8",
+ )
+ source = _source_record(
+ annotation,
+ benchmark="longvideobench",
+ eval_task="longvideobench",
+ family="video_qa",
+ video_root=video_root,
+ subtitle_root=subtitle_root,
+ preprocessed_root=preprocessed_root,
+ )
+ source["legacy_environment"] = {
+ "LONGVIDEOBENCH_DATA_FILE": str(annotation),
+ "LONGVIDEOBENCH_VIDEO_ROOT": str(video_root),
+ "LONGVIDEOBENCH_SUBTITLE_ROOT": str(subtitle_root),
+ }
+ manifest = _write_manifest(tmp_path / "sources.jsonl", [source])
+ return manifest, video_root / "clip.mp4", video_bytes
+
+
+def test_generic_build_stages_subtitles_artifacts_and_is_deterministic(
+ tmp_path: Path,
+) -> None:
+ manifest, source_video, video_bytes = _generic_fixture(tmp_path)
+ copied = tmp_path / "copied"
+ linked = tmp_path / "linked"
+
+ copy_summary = build_evaluation_repository(
+ manifest,
+ copied,
+ copy_mode="copy",
+ checksums=True,
+ )
+ link_summary = build_evaluation_repository(
+ manifest,
+ linked,
+ copy_mode="hardlink",
+ checksums=True,
+ )
+
+ for relative in (
+ "datasets.jsonl",
+ "assets.jsonl",
+ "annotations/video_qa/longvideobench/test.jsonl",
+ ):
+ assert (copied / relative).read_bytes() == (linked / relative).read_bytes()
+ assert copy_summary["datasets"] == 1
+ assert copy_summary["rows"] == 1
+ assert link_summary["hardlinked"] == 3
+
+ digest = hashlib.sha256(video_bytes).hexdigest()
+ video_path = f"media/video_qa/longvideobench/videos/{digest}.mp4"
+ assert (copied / video_path).read_bytes() == video_bytes
+ assert os.stat(source_video).st_ino == os.stat(linked / video_path).st_ino
+
+ rows = load_evaluation_jsonl(
+ copied / "annotations/video_qa/longvideobench/test.jsonl"
+ )
+ row = rows[0]
+ assert row["videos"] == [video_path]
+ assert row["subtitles"][0].startswith(
+ "media/video_qa/longvideobench/subtitles/"
+ )
+ assert row["preprocessed"]["preprocessed_video"].startswith(
+ "artifacts/video_qa/longvideobench/"
+ )
+ assert row["metadata"]["preprocessed_video_settings"] == {"frames": 32}
+ assert row["choices"] == ["A. Nothing", "B. Motion"]
+ assert row["metadata"]["question_category"] == "motion"
+ assert row["task_payload"] == {}
+
+ serialized = (copied / "datasets.jsonl").read_text(encoding="utf-8")
+ serialized += (
+ copied / "annotations/video_qa/longvideobench/test.jsonl"
+ ).read_text(
+ encoding="utf-8"
+ )
+ assert str(tmp_path) not in serialized
+ assert not any(path.is_symlink() for path in copied.rglob("*"))
+ assert validate_staged_repository(copied) == {
+ key: copy_summary[key]
+ for key in ("schema_version", "datasets", "rows", "assets", "bytes")
+ }
+
+
+def test_mvbench_prefers_materialized_path_over_upstream_video_name(
+ tmp_path: Path,
+) -> None:
+ video_root = tmp_path / "MVBench"
+ materialized = video_root / "ssv2_video" / "166583.mp4"
+ materialized.parent.mkdir(parents=True)
+ materialized.write_bytes(b"mvbench-video")
+ annotation = tmp_path / "mvbench.json"
+ annotation.write_text(
+ json.dumps(
+ [
+ {
+ "problem_id": 1,
+ "question": "What happens?",
+ "options": ["A. Nothing", "B. Motion"],
+ "answer": "Motion",
+ "solution": "B ",
+ "data_type": "video",
+ "video": "166583.webm",
+ "path": "./ssv2_video/166583.mp4",
+ }
+ ]
+ ),
+ encoding="utf-8",
+ )
+ manifest = _write_manifest(
+ tmp_path / "sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="mvbench",
+ eval_task="mvbench",
+ family="video_qa",
+ video_root=video_root,
+ )
+ ],
+ )
+ output = tmp_path / "staged"
+
+ report = inventory_evaluation_sources(manifest, checksums=False)
+ summary = build_evaluation_repository(
+ manifest,
+ output,
+ copy_mode="hardlink",
+ checksums=False,
+ )
+
+ assert report["totals"]["missing_assets"] == 0
+ assert report["totals"]["existing_assets"] == 1
+ assert summary["hardlinked"] == 1
+ rows = load_evaluation_jsonl(
+ output / "annotations/video_qa/mvbench/test.jsonl"
+ )
+ assert rows[0]["videos"] == [
+ "media/video_qa/mvbench/videos/test/ssv2_video__166583.mp4"
+ ]
+ assert rows[0]["answer"] == "B"
+
+
+def test_hardlink_falls_back_to_copy(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ manifest, _, _ = _generic_fixture(tmp_path)
+
+ def _fail_link(*args: object, **kwargs: object) -> None:
+ raise OSError("fixture cross-device link")
+
+ monkeypatch.setattr(os, "link", _fail_link)
+ summary = build_evaluation_repository(
+ manifest,
+ tmp_path / "fallback",
+ copy_mode="hardlink",
+ )
+ assert summary["hardlinked"] == 0
+ assert summary["copied"] == 3
+
+
+def test_checksum_free_build_never_reads_asset_contents_for_hashing(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ manifest, _, _ = _generic_fixture(tmp_path)
+ output = tmp_path / "checksum-free"
+
+ def _fail_hash(_path: Path) -> str:
+ pytest.fail("checksum-free build must not calculate SHA-256")
+
+ monkeypatch.setattr(evaluation_converters, "sha256_file", _fail_hash)
+ monkeypatch.setattr(evaluation_staging, "sha256_file", _fail_hash)
+
+ summary = build_evaluation_repository(
+ manifest,
+ output,
+ copy_mode="hardlink",
+ checksums=False,
+ )
+
+ assert summary["asset_checksums"] is False
+ assert (
+ output / "media/video_qa/longvideobench/videos/test/clip.mp4"
+ ).read_bytes() == b"fixture-video"
+ assert all("sha256" not in record for record in load_asset_manifest(output))
+ assert validate_staged_repository(output)["datasets"] == 1
+
+
+def test_checksum_free_build_shortens_long_asset_filenames(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ video_root = tmp_path / "videos"
+ video_root.mkdir()
+ long_name = f"{'long-video-title-' * 14}clip.mp4"
+ source_video = video_root / long_name
+ source_video.write_bytes(b"long-name-video")
+ annotation = tmp_path / "longvideobench.json"
+ annotation.write_text(
+ json.dumps(
+ [
+ {
+ "question_id": "q-1",
+ "question": "What happens?",
+ "options": ["A. Nothing", "B. Motion"],
+ "answer": "B",
+ "data_type": "video",
+ "path": long_name,
+ }
+ ]
+ ),
+ encoding="utf-8",
+ )
+ manifest = _write_manifest(
+ tmp_path / "sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="longvideobench",
+ eval_task="longvideobench",
+ family="video_qa",
+ video_root=video_root,
+ )
+ ],
+ )
+ output = tmp_path / "staged"
+
+ def _fail_hash(_path: Path) -> str:
+ pytest.fail("checksum-free build must not calculate SHA-256")
+
+ monkeypatch.setattr(evaluation_converters, "sha256_file", _fail_hash)
+ monkeypatch.setattr(evaluation_staging, "sha256_file", _fail_hash)
+
+ build_evaluation_repository(
+ manifest,
+ output,
+ copy_mode="hardlink",
+ checksums=False,
+ )
+
+ staged = next(
+ (output / "media/video_qa/longvideobench/videos/test").iterdir()
+ )
+ assert len(staged.name.encode("utf-8")) <= 220
+ assert "--" in staged.name
+ assert staged.suffix == ".mp4"
+ assert staged.read_bytes() == b"long-name-video"
+
+
+def test_incremental_merge_repairs_missing_target_asset_manifest(
+ tmp_path: Path,
+) -> None:
+ first_root = tmp_path / "first"
+ second_root = tmp_path / "second"
+ first_root.mkdir()
+ second_root.mkdir()
+ first_manifest, _, _ = _generic_fixture(first_root)
+ second_manifest, _, _ = _generic_fixture(second_root)
+ second_record = json.loads(second_manifest.read_text(encoding="utf-8"))
+ second_record.update(
+ {
+ "benchmark": "mvbench",
+ "eval_task": "mvbench",
+ "source_url": "https://example.org/mvbench",
+ }
+ )
+ _write_jsonl(second_manifest, [second_record])
+
+ target = tmp_path / "cumulative"
+ source = tmp_path / "next-task"
+ build_evaluation_repository(first_manifest, target, copy_mode="hardlink")
+ build_evaluation_repository(second_manifest, source, copy_mode="hardlink")
+ (target / "assets.jsonl").unlink()
+
+ summary = merge_evaluation_repository(
+ source,
+ target,
+ repair_missing_asset_manifest=True,
+ workers=2,
+ )
+
+ assert summary["datasets"] == 2
+ assert summary["rows"] == 2
+ assert summary["assets"] == 8
+ assert summary["hardlinked"] == 4
+ assert summary["repaired_asset_manifest"] is True
+ assert summary["workers"] == 2
+ assert (target / "annotations/video_qa/mvbench/test.jsonl").is_file()
+ assert validate_staged_repository(target)["datasets"] == 2
+
+
+def test_incremental_merge_can_replace_legacy_benchmark_layout(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ manifest, _, _ = _generic_fixture(tmp_path)
+ target = tmp_path / "legacy-target"
+ source = tmp_path / "grouped-source"
+
+ with monkeypatch.context() as legacy_layout:
+ legacy_layout.delitem(
+ evaluation_layout.BENCHMARK_GROUPS,
+ "longvideobench",
+ )
+ build_evaluation_repository(manifest, target, copy_mode="hardlink")
+
+ assert (target / "annotations/longvideobench/test.jsonl").is_file()
+ build_evaluation_repository(manifest, source, copy_mode="hardlink")
+
+ summary = merge_evaluation_repository(
+ source,
+ target,
+ replace_benchmarks=["longvideobench"],
+ workers=2,
+ )
+
+ assert summary["replaced_benchmarks"] == ["longvideobench"]
+ assert summary["removed"] == 4
+ assert not (target / "annotations/longvideobench").exists()
+ assert not (target / "media/longvideobench").exists()
+ assert not (target / "artifacts/longvideobench").exists()
+ assert (
+ target / "annotations/video_qa/longvideobench/test.jsonl"
+ ).is_file()
+ assert validate_staged_repository(target)["datasets"] == 1
+
+
+def test_incremental_merge_replaces_changed_asset_at_the_same_path(
+ tmp_path: Path,
+) -> None:
+ target_input = tmp_path / "target-input"
+ source_input = tmp_path / "source-input"
+ target_input.mkdir()
+ source_input.mkdir()
+ target_manifest, _, _ = _generic_fixture(target_input)
+ source_manifest, _, _ = _generic_fixture(source_input)
+ source_annotation = source_input / "video-qa.json"
+ source_rows = json.loads(source_annotation.read_text(encoding="utf-8"))
+ source_rows[0]["answer"] = "updated-answer-with-a-different-size"
+ source_annotation.write_text(json.dumps(source_rows), encoding="utf-8")
+
+ target = tmp_path / "target"
+ source = tmp_path / "source"
+ build_evaluation_repository(target_manifest, target, copy_mode="hardlink")
+ build_evaluation_repository(source_manifest, source, copy_mode="hardlink")
+
+ summary = merge_evaluation_repository(
+ source,
+ target,
+ replace_benchmarks=["longvideobench"],
+ workers=2,
+ )
+
+ rows = load_evaluation_jsonl(
+ target / "annotations/video_qa/longvideobench/test.jsonl"
+ )
+ assert rows[0]["answer"] == "updated-answer-with-a-different-size"
+ assert summary["hardlinked"] == 1
+ assert summary["existing"] == 3
+ assert summary["removed"] == 0
+ assert validate_staged_repository(target)["datasets"] == 1
+
+
+def test_inventory_is_read_only_and_reports_missing_assets(tmp_path: Path) -> None:
+ annotation = tmp_path / "missing.json"
+ annotation.write_text(
+ json.dumps(
+ [
+ {
+ "id": "missing-1",
+ "question": "Where is the clip?",
+ "answer": "A",
+ "video": "not-there.mp4",
+ }
+ ]
+ ),
+ encoding="utf-8",
+ )
+ video_root = tmp_path / "video-root"
+ video_root.mkdir()
+ manifest = _write_manifest(
+ tmp_path / "missing-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="missing_bench",
+ eval_task="video_qa",
+ family="video_qa",
+ video_root=video_root,
+ )
+ ],
+ )
+ before = sorted(path.relative_to(tmp_path) for path in tmp_path.rglob("*"))
+
+ inventory = inventory_evaluation_sources(manifest, workers=2)
+
+ after = sorted(path.relative_to(tmp_path) for path in tmp_path.rglob("*"))
+ assert before == after
+ assert inventory["totals"]["source_rows"] == 1
+ assert inventory["totals"]["rows"] == 1
+ assert inventory["totals"]["referenced_assets"] == 1
+ assert inventory["totals"]["missing_assets"] == 1
+ target = tmp_path / "must-not-exist"
+ with pytest.raises(StagingError, match="referenced asset"):
+ build_evaluation_repository(manifest, target)
+ assert not target.exists()
+
+
+def test_repeated_asset_references_are_resolved_once(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ manifest, _, _ = _generic_fixture(tmp_path)
+ source = load_source_manifest(manifest)[0]
+ planner = evaluation_converters._AssetPlanner(source, hash_assets=False)
+ root = source.media_root("videos")
+ original_is_file = Path.is_file
+ checks = 0
+
+ def _counted_is_file(path: Path) -> bool:
+ nonlocal checks
+ if path.name == "clip.mp4":
+ checks += 1
+ return original_is_file(path)
+
+ monkeypatch.setattr(Path, "is_file", _counted_is_file)
+
+ for _ in range(20):
+ assert planner.resolve("clip.mp4", root).name == "clip.mp4"
+
+ assert checks == 1
+
+
+def test_parallel_inventory_matches_single_worker(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ video_root = tmp_path / "videos"
+ video_root.mkdir()
+ (video_root / "clip.mp4").write_bytes(b"video")
+ records = []
+ for name in ("first", "second"):
+ annotation = tmp_path / f"{name}.json"
+ annotation.write_text(
+ json.dumps(
+ [
+ {
+ "id": name,
+ "question": f"Question {name}?",
+ "answer": "A",
+ "video": "clip.mp4",
+ }
+ ]
+ ),
+ encoding="utf-8",
+ )
+ records.append(
+ _source_record(
+ annotation,
+ benchmark=name,
+ eval_task="video_qa",
+ family="video_qa",
+ video_root=video_root,
+ )
+ )
+ manifest = _write_manifest(tmp_path / "parallel.jsonl", records)
+
+ sequential = inventory_evaluation_sources(manifest, workers=1)
+ parallel = inventory_evaluation_sources(manifest, workers=4)
+
+ assert parallel == sequential
+ monkeypatch.setattr(
+ evaluation_converters,
+ "sha256_file",
+ lambda _path: pytest.fail("fast inventory must not hash media"),
+ )
+ fast = inventory_evaluation_sources(
+ manifest,
+ workers=4,
+ checksums=False,
+ )
+ assert fast["asset_checksums"] is False
+ assert fast["totals"] == sequential["totals"]
+ with pytest.raises(ValueError, match="workers"):
+ inventory_evaluation_sources(manifest, workers=0)
+
+
+def test_mmsi_embedded_images_are_decoded_to_content_addressed_files(
+ tmp_path: Path,
+) -> None:
+ image_bytes = b"\xff\xd8\xff\xe0fixture-jpeg"
+ encoded = base64.b64encode(image_bytes).decode("ascii")
+ annotation = tmp_path / "mmsi.tsv"
+ annotation.write_text(
+ "id\tquestion\tanswer\timage\tgroup\n"
+ f"m-1\tWhich view? A. left B. right\tA\t{[encoded]!r}\trotation\n",
+ encoding="utf-8",
+ )
+ manifest = _write_manifest(
+ tmp_path / "mmsi-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="mmsi",
+ eval_task="mmsi",
+ family="spatial_intelligence",
+ adapter="mmsi",
+ )
+ ],
+ )
+ output = tmp_path / "mmsi-output"
+
+ build_evaluation_repository(manifest, output, checksums=True)
+
+ row = load_evaluation_jsonl(
+ output / "annotations/spatial_intelligence/mmsi/test.jsonl"
+ )[0]
+ image_path = row["images"][0]
+ assert image_path == (
+ "media/spatial_intelligence/mmsi/images/"
+ f"{hashlib.sha256(image_bytes).hexdigest()}.jpg"
+ )
+ assert (output / image_path).read_bytes() == image_bytes
+ assert "image" not in row.get("metadata", {})
+
+
+def test_timelens_nested_annotations_flatten_per_query_and_span(tmp_path: Path) -> None:
+ video_root = tmp_path / "timelens-videos"
+ video_root.mkdir()
+ (video_root / "clip.mp4").write_bytes(b"timelens video")
+ annotation = tmp_path / "timelens.json"
+ annotation.write_text(
+ json.dumps(
+ {
+ "clip": {
+ "duration": 12.5,
+ "queries": ["opens door.", "sits down"],
+ "spans": [[1.0, 2.5], [[7.0, 8.0], [9.0, 10.0]]],
+ "group": "charades",
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ manifest = _write_manifest(
+ tmp_path / "timelens-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="charades_timelens",
+ eval_task="temporal_grounding",
+ family="temporal_grounding",
+ adapter="timelens",
+ expected_count=2,
+ video_root=video_root,
+ )
+ ],
+ )
+ output = tmp_path / "timelens-output"
+
+ build_evaluation_repository(manifest, output)
+
+ rows = load_evaluation_jsonl(
+ output / "annotations/charades_timelens/test.jsonl"
+ )
+ assert [row["sample_id"] for row in rows] == ["clip:000000", "clip:000001"]
+ assert [row["problem"] for row in rows] == ["opens door", "sits down"]
+ assert rows[0]["task_payload"] == {"duration": 12.5, "span": [1.0, 2.5]}
+ assert rows[1]["answer"] == [[7.0, 8.0], [9.0, 10.0]]
+ assert rows[0]["metadata"]["group"] == "charades"
+ media_assets = [
+ asset
+ for asset in load_asset_manifest(output)
+ if asset["kind"] == "videos"
+ ]
+ assert len(media_assets) == 1
+
+
+def test_generic_spatial_grounding_preserves_payload_and_metadata(
+ tmp_path: Path,
+) -> None:
+ image_root = tmp_path / "images"
+ image_root.mkdir()
+ (image_root / "scene.jpg").write_bytes(b"fixture image")
+ annotation = tmp_path / "grounding.jsonl"
+ _write_jsonl(
+ annotation,
+ [
+ {
+ "id": "g-1",
+ "normal_caption": "the red box",
+ "problem": (
+ "Please provide the bounding box coordinate of the region "
+ "this sentence describes: the red box."
+ ),
+ "solution": [10.0, 20.0, 300.0, 400.0],
+ "normalized_solution": [16, 47, 469, 935],
+ "image": "scene.jpg",
+ "dataset_group": "refcoco_val",
+ }
+ ],
+ )
+ manifest = _write_manifest(
+ tmp_path / "grounding-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="refcoco",
+ eval_task="spatial_grounding",
+ family="spatial_grounding",
+ adapter="spatial_grounding",
+ image_root=image_root,
+ )
+ ],
+ )
+ output = tmp_path / "grounding-output"
+
+ build_evaluation_repository(manifest, output)
+
+ row = load_evaluation_jsonl(output / "annotations/refcoco/test.jsonl")[0]
+ assert row["problem"] == "the red box"
+ assert row["answer"] == [16, 47, 469, 935]
+ assert row["task_payload"]["normalized_solution"] == [16, 47, 469, 935]
+ assert row["metadata"]["dataset_group"] == "refcoco_val"
+ assert row["images"][0].startswith("media/refcoco/images/")
+
+
+def test_onethinker_generic_adapter_extracts_structured_ground_truth(
+ tmp_path: Path,
+) -> None:
+ video_root = tmp_path / "tracking-videos"
+ video_root.mkdir()
+ (video_root / "track.mp4").write_bytes(b"tracking video")
+ annotation = tmp_path / "tracking.json"
+ annotation.write_text(
+ json.dumps(
+ [
+ {
+ "problem_id": 7,
+ "data_type": "video",
+ "problem_type": "tracking",
+ "problem": "Track the target.",
+ "path": "track.mp4",
+ "solution": (
+ '{"boxes":{"1":[1,2,3,4],'
+ '"2":[2,3,4,5]}} '
+ ),
+ }
+ ]
+ ),
+ encoding="utf-8",
+ )
+ manifest = _write_manifest(
+ tmp_path / "tracking-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="got10k",
+ eval_task="tracking",
+ family="tracking",
+ adapter="one_thinker",
+ video_root=video_root,
+ )
+ ],
+ )
+ output = tmp_path / "tracking-output"
+
+ build_evaluation_repository(manifest, output)
+
+ row = load_evaluation_jsonl(output / "annotations/got10k/test.jsonl")[0]
+ assert row["task_payload"]["boxes"]["1"] == [1, 2, 3, 4]
+ assert row["metadata"]["data_type"] == "video"
+
+
+def test_segmentation_stages_media_hints_and_mask_oracles(tmp_path: Path) -> None:
+ media_root = tmp_path / "OneThinker-eval"
+ image = media_root / "Evaluation" / "Refcoco" / "cat.jpg"
+ image.parent.mkdir(parents=True)
+ image.write_bytes(b"segmentation image")
+ annotation = tmp_path / "segmentation.json"
+ annotation.write_text(
+ json.dumps(
+ [
+ {
+ "problem_id": 9,
+ "data_type": "image",
+ "problem_type": "segmentation",
+ "problem": "Segment the cat.",
+ "path": "./Evaluation/Refcoco/cat.jpg",
+ "solution": (
+ '{"boxes":[100,100,800,800],'
+ '"positive_points":[[300,300]],'
+ '"negative_points":[[20,20]]} '
+ ),
+ "segmentation_output": {
+ "size": [2, 2],
+ "counts": [0, 1, 3],
+ },
+ },
+ {
+ "problem_id": 10,
+ "data_type": "image",
+ "problem_type": "segmentation",
+ "problem": "Segment the unlabeled benchmark object.",
+ "path": "./Evaluation/Refcoco/cat.jpg",
+ "solution": "",
+ "segmentation_output": {
+ "frames": ["00000"],
+ "segmentation_rle": {
+ "00000": {"size": [2, 2], "counts": "13"}
+ },
+ },
+ },
+ ]
+ ),
+ encoding="utf-8",
+ )
+ manifest = _write_manifest(
+ tmp_path / "segmentation-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="segmentation",
+ eval_task="segmentation",
+ family="segmentation",
+ adapter="one_thinker",
+ expected_count=2,
+ media_root=media_root,
+ )
+ ],
+ )
+ output = tmp_path / "segmentation-output"
+
+ build_evaluation_repository(manifest, output)
+
+ rows = load_evaluation_jsonl(output / "annotations/segmentation/test.jsonl")
+ row = next(item for item in rows if item["sample_id"] == "9")
+ assert row["problem_type"] == "segmentation"
+ assert row["metadata"]["data_type"] == "image"
+ assert row["task_payload"]["boxes"] == [100, 100, 800, 800]
+ assert row["task_payload"]["positive_points"] == [[300, 300]]
+ assert row["task_payload"]["segmentation_output"]["size"] == [2, 2]
+ assert (output / row["images"][0]).read_bytes() == b"segmentation image"
+ mask_only = next(item for item in rows if item["sample_id"] == "10")
+ assert mask_only["answer"] is None
+ assert mask_only["task_payload"]["segmentation_output"]["frames"] == ["00000"]
+
+
+def test_mindcube_parquet_materializes_embedded_images_lazily(tmp_path: Path) -> None:
+ pd = pytest.importorskip("pandas")
+ pytest.importorskip("pyarrow")
+ image_bytes = b"\x89PNG\r\n\x1a\nfixture"
+ annotation = tmp_path / "mindcube.parquet"
+ pd.DataFrame(
+ [
+ {
+ "id": "rotation_1",
+ "question": "Which direction?",
+ "choices": ["left", "right"],
+ "_".join(("gt", "answer")): 0,
+ "images": [{"bytes": image_bytes, "path": None}],
+ }
+ ]
+ ).to_parquet(annotation)
+ manifest = _write_manifest(
+ tmp_path / "mindcube-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="mindcube",
+ eval_task="mindcube",
+ family="spatial_intelligence",
+ adapter="mindcube",
+ )
+ ],
+ )
+ output = tmp_path / "mindcube-output"
+
+ build_evaluation_repository(manifest, output)
+
+ row = load_evaluation_jsonl(
+ output / "annotations/spatial_intelligence/mindcube/test.jsonl"
+ )[0]
+ assert row["answer"] == 0
+ assert row["choices"] == ["left", "right"]
+ assert (output / row["images"][0]).read_bytes() == image_bytes
+ assert "_".join(("gt", "answer")) not in row.get("metadata", {})
+
+
+def test_mindcube_official_distribution_is_strict(tmp_path: Path) -> None:
+ annotation = tmp_path / "mindcube.parquet"
+ annotation.write_bytes(b"fixture")
+ manifest = _write_manifest(
+ tmp_path / "mindcube-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="mindcube",
+ eval_task="mindcube",
+ family="spatial_intelligence",
+ adapter="mindcube",
+ expected_count=1050,
+ )
+ ],
+ )
+ source = load_source_manifest(manifest)[0]
+ records = [
+ {"id": f"{group}_{index}"}
+ for group, count in {"rotation": 200, "among": 600, "around": 250}.items()
+ for index in range(count)
+ ]
+
+ evaluation_converters._validate_official_mindcube(records, source)
+
+ records[-1] = {"id": "unknown_0"}
+ with pytest.raises(
+ evaluation_converters.ConversionError,
+ match="official MindCube-Tiny must contain",
+ ):
+ evaluation_converters._validate_official_mindcube(records, source)
+
+
+def test_revsi_parquet_stages_all_frame_videos(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ annotation = tmp_path / "revsi.parquet"
+ annotation.write_bytes(b"fixture")
+ video_root = tmp_path / "all_frame"
+ video_root.mkdir()
+ (video_root / "scene-1.mp4").write_bytes(b"revsi-video")
+ manifest = _write_manifest(
+ tmp_path / "revsi-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="revsi",
+ eval_task="revsi",
+ family="spatial_intelligence",
+ adapter="revsi",
+ video_root=video_root,
+ )
+ ],
+ )
+ monkeypatch.setattr(
+ evaluation_converters,
+ "_load_parquet",
+ lambda _source: [
+ {
+ "id": "sample-1",
+ "scene_id": "scene-1",
+ "question": "How many chairs are visible?",
+ "ground_truth": 3,
+ "question_type": "object_counting_single",
+ "num_frames": "all",
+ }
+ ],
+ )
+ output = tmp_path / "revsi-output"
+
+ build_evaluation_repository(manifest, output)
+
+ row = load_evaluation_jsonl(
+ output / "annotations/spatial_intelligence/revsi/test.jsonl"
+ )[0]
+ assert row["answer"] == 3
+ assert row["problem_type"] == "object_counting_single"
+ assert row["videos"] == [
+ "media/spatial_intelligence/revsi/videos/test/scene-1.mp4"
+ ]
+ assert row["metadata"]["scene_id"] == "scene-1"
+ assert row["metadata"]["num_frames"] == "all"
+ assert (output / row["videos"][0]).read_bytes() == b"revsi-video"
+
+
+def test_revsi_official_distribution_is_strict(tmp_path: Path) -> None:
+ annotation = tmp_path / "revsi.parquet"
+ annotation.write_bytes(b"fixture")
+ manifest = _write_manifest(
+ tmp_path / "revsi-sources.jsonl",
+ [
+ _source_record(
+ annotation,
+ benchmark="revsi",
+ eval_task="revsi",
+ family="spatial_intelligence",
+ adapter="revsi",
+ expected_count=6808,
+ )
+ ],
+ )
+ source = load_source_manifest(manifest)[0]
+ expected = evaluation_converters._REVSI_EXPECTED_GROUP_COUNTS
+ representative_types = {
+ "object_counting": "object_counting_single",
+ "object_rel_direction": "object_rel_direction_forward_easy",
+ "object_rel_distance": "object_rel_distance_closest",
+ "room_size_estimation": "room_size_estimation_single",
+ }
+ records = [
+ {"question_type": representative_types.get(group, group)}
+ for group, count in expected.items()
+ for _index in range(count)
+ ]
+
+ evaluation_converters._validate_official_revsi(records, source)
+
+ records[-1] = {"question_type": "unknown"}
+ with pytest.raises(
+ evaluation_converters.ConversionError,
+ match="official ReVSI all-frame split must contain",
+ ):
+ evaluation_converters._validate_official_revsi(records, source)
+
+
+def test_validation_detects_unreferenced_files_and_checksum_changes(
+ tmp_path: Path,
+) -> None:
+ manifest, _, _ = _generic_fixture(tmp_path)
+ output = tmp_path / "validated"
+ build_evaluation_repository(manifest, output, checksums=True)
+ video = next(
+ path
+ for path in (output / "media/video_qa/longvideobench/videos").rglob("*")
+ if path.is_file()
+ )
+ original = video.read_bytes()
+
+ video.write_bytes(b"x" * len(original))
+ with pytest.raises(StagingError, match="mismatch"):
+ validate_staged_repository(output, checksums=True)
+ video.write_bytes(original)
+
+ extra = output / "media/video_qa/longvideobench/videos/unreferenced.mp4"
+ extra.write_bytes(b"extra")
+ with pytest.raises(StagingError, match="unmanifested"):
+ validate_staged_repository(output, checksums=True)
+
+
+def test_build_refuses_existing_target_without_overwrite(tmp_path: Path) -> None:
+ manifest, _, _ = _generic_fixture(tmp_path)
+ output = tmp_path / "existing"
+ build_evaluation_repository(manifest, output)
+
+ with pytest.raises(FileExistsError, match="refusing to overwrite"):
+ build_evaluation_repository(manifest, output)
+ build_evaluation_repository(manifest, output, overwrite=True)
+ validate_staged_repository(output)
+
+
+def test_eval_data_command_exposes_inventory_build_and_validate(
+ tmp_path: Path,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ manifest, _, _ = _generic_fixture(tmp_path)
+ output = tmp_path / "cli-output"
+ parser = eval_data.create_parser()
+ assert parser.parse_args(["inventory", str(manifest)]).checksums is False
+ assert (
+ parser.parse_args(["inventory", str(manifest), "--checksums"]).checksums
+ is True
+ )
+
+ assert (
+ eval_data.main(
+ [
+ "inventory",
+ str(manifest),
+ "--workers",
+ "2",
+ ]
+ )
+ == 0
+ )
+ inventory = json.loads(capsys.readouterr().out)
+ assert inventory["totals"]["rows"] == 1
+ assert inventory["asset_checksums"] is False
+ assert (
+ eval_data.main(
+ [
+ "build",
+ str(manifest),
+ str(output),
+ "--workers",
+ "2",
+ ]
+ )
+ == 0
+ )
+ build_summary = json.loads(capsys.readouterr().out)
+ assert build_summary["datasets"] == 1
+ assert build_summary["asset_checksums"] is False
+ assert eval_data.main(["validate", str(output)]) == 0
+ assert json.loads(capsys.readouterr().out)["rows"] == 1
diff --git a/code/tests/test_organize_evaluation_outputs.py b/code/tests/test_organize_evaluation_outputs.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ff1d6ba2073af705bf223331f9c859824a9f372
--- /dev/null
+++ b/code/tests/test_organize_evaluation_outputs.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+SCRIPT = Path(__file__).parents[1] / "scripts" / "organize_evaluation_outputs.py"
+
+
+def _run(root: Path, *arguments: str) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ [sys.executable, str(SCRIPT), str(root), *arguments],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_output_organizer_previews_then_groups_legacy_tasks(tmp_path: Path) -> None:
+ root = tmp_path / "Video-ORA-9B"
+ legacy_video_qa = root / "mvbench" / "base" / "run"
+ legacy_spatial = root / "vsi" / "base" / "run"
+ legacy_revsi = root / "revsi" / "base" / "run"
+ legacy_tracking = root / "tracking" / "base" / "run"
+ for directory in (
+ legacy_video_qa,
+ legacy_spatial,
+ legacy_revsi,
+ legacy_tracking,
+ ):
+ directory.mkdir(parents=True)
+ (directory / "summary.json").write_text("{}\n", encoding="utf-8")
+
+ preview = _run(root)
+
+ assert preview.returncode == 0
+ assert "mvbench -> video_qa/mvbench" in preview.stdout
+ assert legacy_video_qa.is_dir()
+
+ applied = _run(root, "--apply")
+
+ assert applied.returncode == 0
+ assert (root / "video_qa/mvbench/base/run/summary.json").is_file()
+ assert (root / "spatial_intelligence/vsi/base/run/summary.json").is_file()
+ assert (root / "spatial_intelligence/revsi/base/run/summary.json").is_file()
+ assert (legacy_tracking / "summary.json").is_file()
+ assert not (root / "mvbench").exists()
+ assert not (root / "vsi").exists()
+ assert not (root / "revsi").exists()
+
+
+def test_output_organizer_refuses_to_overwrite_results(tmp_path: Path) -> None:
+ root = tmp_path / "Video-ORA-9B"
+ source = root / "mvbench" / "base" / "run"
+ destination = root / "video_qa/mvbench/base/run"
+ source.mkdir(parents=True)
+ destination.mkdir(parents=True)
+ (source / "summary.json").write_text('{"source": true}\n', encoding="utf-8")
+ (destination / "summary.json").write_text(
+ '{"destination": true}\n',
+ encoding="utf-8",
+ )
+
+ completed = _run(root, "--apply")
+
+ assert completed.returncode != 0
+ assert "refusing to overwrite existing output" in completed.stderr
+ assert (source / "summary.json").is_file()
+ assert (destination / "summary.json").is_file()
diff --git a/code/tests/test_release_hygiene.py b/code/tests/test_release_hygiene.py
new file mode 100644
index 0000000000000000000000000000000000000000..f128899a704dd21ec889e408a573d3624dc88d25
--- /dev/null
+++ b/code/tests/test_release_hygiene.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+CHECKER_PATH = RELEASE_ROOT / "scripts" / "check_release.py"
+SPEC = importlib.util.spec_from_file_location("orarl_release_check", CHECKER_PATH)
+assert SPEC is not None and SPEC.loader is not None
+CHECKER = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = CHECKER
+SPEC.loader.exec_module(CHECKER)
+
+
+def _reasons(root: Path, max_bytes: int = CHECKER.DEFAULT_MAX_BYTES) -> list[str]:
+ return [finding.reason for finding in CHECKER.check_release(root, max_bytes)]
+
+
+def test_clean_text_tree_passes(tmp_path: Path) -> None:
+ (tmp_path / "README.md").write_text("portable source\n", encoding="utf-8")
+ assert CHECKER.check_release(tmp_path) == []
+
+
+def test_portable_evaluation_jsonl_is_release_source(tmp_path: Path) -> None:
+ annotation = tmp_path / "data" / "eval" / "annotations" / "task.jsonl"
+ annotation.parent.mkdir(parents=True)
+ natural_text = "".join(("fl", "uffy"))
+ annotation.write_text(
+ f'{{"problem":"{natural_text}","videos":["media/task/videos/clip.mp4"]}}\n',
+ encoding="utf-8",
+ )
+
+ assert CHECKER.check_release(tmp_path) == []
+
+
+def test_canonical_evaluation_jsonl_can_exceed_the_generic_source_limit(
+ tmp_path: Path,
+) -> None:
+ annotation = tmp_path / "data" / "eval" / "annotations" / "task.jsonl"
+ annotation.parent.mkdir(parents=True)
+ annotation.write_text('{"problem":"' + ("x" * 64) + '"}\n', encoding="utf-8")
+
+ assert CHECKER.check_release(tmp_path, max_bytes=16) == []
+
+
+def test_evaluation_jsonl_still_rejects_private_paths(tmp_path: Path) -> None:
+ annotation = tmp_path / "data" / "eval" / "annotations" / "task.jsonl"
+ annotation.parent.mkdir(parents=True)
+ private_path = "/" + "mnt" + "/person/video.mp4"
+ annotation.write_text(
+ f'{{"videos":["{private_path}"]}}\n',
+ encoding="utf-8",
+ )
+
+ assert "private absolute path at line 1" in _reasons(tmp_path)
+
+
+def test_non_evaluation_data_tree_is_rejected(tmp_path: Path) -> None:
+ generated = tmp_path / "data" / "training"
+ generated.mkdir(parents=True)
+ (generated / "rows.txt").write_text("private input\n", encoding="utf-8")
+
+ assert "generated root data directory" in _reasons(tmp_path)
+
+
+def test_excluded_method_terms_are_detected_without_literals_in_test_source(
+ tmp_path: Path,
+) -> None:
+ term_one = "".join(("c", "p", "p", "o"))
+ term_two = "".join(("lu", "ff", "y"))
+ (tmp_path / "source.py").write_text(
+ f"first = '{term_one}'\nsecond = '{term_two}'\n",
+ encoding="utf-8",
+ )
+ reasons = _reasons(tmp_path)
+ assert sum("excluded method term" in reason for reason in reasons) == 2
+
+
+def test_legacy_oracle_names_are_detected(tmp_path: Path) -> None:
+ legacy_name = "".join(("build_", "g", "t", "_response"))
+ (tmp_path / "source.py").write_text(
+ f"def {legacy_name}():\n pass\n",
+ encoding="utf-8",
+ )
+ assert any("excluded method term" in reason for reason in _reasons(tmp_path))
+
+
+def test_evaluation_runtime_may_name_benchmark_ground_truth(tmp_path: Path) -> None:
+ evaluator = tmp_path / "eval" / "task" / "tracking" / "eval_tracking.py"
+ evaluator.parent.mkdir(parents=True)
+ annotation_field = "".join(("g", "t", "_", "bbox"))
+ evaluator.write_text(
+ f'box = record["{annotation_field}"]\n',
+ encoding="utf-8",
+ )
+
+ assert CHECKER.check_release(tmp_path) == []
+
+
+def test_evaluation_runtime_still_rejects_excluded_method_terms(tmp_path: Path) -> None:
+ evaluator = tmp_path / "eval" / "task" / "eval_vllm.py"
+ evaluator.parent.mkdir(parents=True)
+ legacy_name = "".join(("build_", "g", "t", "_response"))
+ evaluator.write_text(f"def {legacy_name}():\n pass\n", encoding="utf-8")
+
+ assert any("excluded method term" in reason for reason in _reasons(tmp_path))
+
+
+def test_generated_test_caches_fail_the_source_gate(tmp_path: Path) -> None:
+ cache = tmp_path / ".pytest_cache"
+ cache.mkdir()
+ (cache / "state").write_text("generated\n", encoding="utf-8")
+ (tmp_path / "README.md").write_text("portable source\n", encoding="utf-8")
+ findings = CHECKER.check_release(tmp_path)
+ assert len(findings) == 1
+ assert findings[0].path == Path(".pytest_cache")
+ assert findings[0].reason == "generated directory"
+
+
+def test_gitignored_generated_state_is_not_part_of_release(tmp_path: Path) -> None:
+ cache = tmp_path / ".pytest_cache"
+ cache.mkdir()
+ (cache / "state").write_text("generated\n", encoding="utf-8")
+ (tmp_path / ".gitignore").write_text(".pytest_cache/\n", encoding="utf-8")
+
+ assert CHECKER.check_release(tmp_path) == []
+
+
+def test_gitignored_training_runs_are_not_part_of_release(tmp_path: Path) -> None:
+ run = tmp_path / "runs" / "smoke-training" / "grpo"
+ run.mkdir(parents=True)
+ (run / "smoke.log").write_text("/private/training/path\n", encoding="utf-8")
+ (tmp_path / ".gitignore").write_text("/runs/\n", encoding="utf-8")
+
+ assert CHECKER.check_release(tmp_path) == []
+
+
+def test_local_checkpoint_tree_is_outside_release_scan(tmp_path: Path) -> None:
+ checkpoint = tmp_path / "checkpoint" / "Video-ORA-9B"
+ checkpoint.mkdir(parents=True)
+ (checkpoint / "model.safetensors").write_bytes(b"local weights")
+ (tmp_path / "README.md").write_text("portable source\n", encoding="utf-8")
+ assert CHECKER.check_release(tmp_path) == []
+
+
+def test_documented_release_media_is_allowlisted(tmp_path: Path) -> None:
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ (assets / "orarl-data-scaling.gif").write_bytes(b"documented data animation")
+ (assets / "orarl-hero.gif").write_bytes(b"documented preview")
+ (assets / "orarl-method.gif").write_bytes(b"documented method animation")
+ (assets / "orarl-model-scaling.gif").write_bytes(b"documented model animation")
+ (assets / "paper-results.png").write_bytes(b"documented figure")
+ (tmp_path / "orarl.pdf").write_bytes(b"documented paper")
+
+ assert CHECKER.check_release(tmp_path) == []
+
+
+def test_private_paths_and_secret_values_are_detected(tmp_path: Path) -> None:
+ private_path = "/" + "mnt" + "/person/project/model"
+ access_value = "AKIA" + ("A" * 16)
+ (tmp_path / "settings.txt").write_text(
+ f"model={private_path}\ncloud={access_value}\n",
+ encoding="utf-8",
+ )
+ reasons = _reasons(tmp_path)
+ assert any("private absolute path" in reason for reason in reasons)
+ assert any("credential-like value" in reason for reason in reasons)
+
+
+def test_generated_large_and_broken_entries_are_detected(tmp_path: Path) -> None:
+ (tmp_path / "weights.pt").write_bytes(b"payload")
+ (tmp_path / "large.txt").write_text("x" * 17, encoding="utf-8")
+ (tmp_path / "missing-link").symlink_to(tmp_path / "not-there")
+ reasons = _reasons(tmp_path, max_bytes=16)
+ assert "generated or binary artifact" in reasons
+ assert any("file is too large" in reason for reason in reasons)
+ assert "broken symbolic link" in reasons
diff --git a/code/tests/test_resources.py b/code/tests/test_resources.py
new file mode 100644
index 0000000000000000000000000000000000000000..50013288dd14e1a6e30364d0263fef570924b436
--- /dev/null
+++ b/code/tests/test_resources.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from orarl import (
+ available_configs,
+ config_path,
+ documentation_path,
+ script_path,
+)
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_source_resources_are_discoverable() -> None:
+ assert available_configs() == (
+ "grpo_4b.yaml",
+ "grpo_9b.yaml",
+ "orarl_4b.yaml",
+ "orarl_9b.yaml",
+ )
+ assert config_path("orarl_4b.yaml") == (RELEASE_ROOT / "configs" / "orarl_4b.yaml")
+ assert documentation_path("environment.md") == (RELEASE_ROOT / "docs" / "environment.md")
+ assert documentation_path("training.md") == (RELEASE_ROOT / "docs" / "training.md")
+ assert documentation_path("evaluation.md") == (RELEASE_ROOT / "docs" / "evaluation.md")
+ assert script_path("train_orarl.sh") == (RELEASE_ROOT / "scripts" / "train_orarl.sh")
+
+
+@pytest.mark.parametrize("name", ("", ".", "..", "../orarl_4b.yaml"))
+def test_resource_names_reject_path_traversal(name: str) -> None:
+ with pytest.raises(ValueError, match="file name"):
+ config_path(name)
+
+
+def test_unknown_resource_has_clear_error() -> None:
+ with pytest.raises(FileNotFoundError, match="not found"):
+ config_path("unknown.yaml")
diff --git a/code/tests/test_rewards.py b/code/tests/test_rewards.py
new file mode 100644
index 0000000000000000000000000000000000000000..7c134db475a3143d6491e09a22fa0ca9edcdd48a
--- /dev/null
+++ b/code/tests/test_rewards.py
@@ -0,0 +1,342 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import orarl.rewards.router as router_module
+from orarl.rewards import (
+ RewardContractError,
+ RewardRouter,
+ RouterSettings,
+ TaskFamily,
+ UnknownTaskError,
+ build_oracle_response_from_ground_truth,
+ compute_reward,
+)
+
+
+class FakeAdapter:
+ def __init__(self, family: TaskFamily, value: float) -> None:
+ self.family = family
+ self.value = value
+ self.scored_batches: list[list[dict[str, Any]]] = []
+ self.score_options: list[dict[str, Any]] = []
+ self.oracle_calls: list[tuple[Any, Any]] = []
+
+ def compute_score(self, batch, **kwargs):
+ self.scored_batches.append(batch)
+ self.score_options.append(kwargs)
+ return [
+ {
+ "overall": self.value,
+ "format": 1.0,
+ "response_length_seen": float(len(item.get("response", ""))),
+ }
+ for item in batch
+ ]
+
+ def build_oracle_response_from_ground_truth(
+ self,
+ ground_truth,
+ extra=None,
+ ):
+ self.oracle_calls.append((ground_truth, extra))
+ return f"{self.family.value}:{ground_truth}"
+
+
+def fake_adapters() -> dict[TaskFamily, FakeAdapter]:
+ return {
+ family: FakeAdapter(family, (index + 1) / 10.0) for index, family in enumerate(TaskFamily)
+ }
+
+
+@pytest.mark.parametrize(
+ ("problem_type", "expected"),
+ [
+ ("Temporal Grounding", TaskFamily.TEMPORAL_GROUNDING),
+ ("video_tracking", TaskFamily.TRACKING),
+ ("image-segmentation", TaskFamily.SEGMENTATION),
+ ("RefCOCO", TaskFamily.SPATIAL_GROUNDING),
+ ("spatial-temporal grounding", TaskFamily.SPATIAL_TEMPORAL_GROUNDING),
+ ("object_rel_direction_hard", TaskFamily.SPATIAL_INTELLIGENCE),
+ ("video_qa_mc", TaskFamily.VIDEO_QA),
+ ],
+)
+def test_routes_all_seven_families(problem_type, expected):
+ adapters = fake_adapters()
+ router = RewardRouter(RouterSettings(adapters=adapters))
+
+ score = router.compute_reward(
+ {"problem_type": problem_type, "ground_truth": "unused"},
+ "response",
+ )
+
+ assert score["overall"] == adapters[expected].value
+ assert len(adapters[expected].scored_batches) == 1
+
+
+def test_mixed_batch_groups_adapters_and_restores_order():
+ adapters = fake_adapters()
+ router = RewardRouter(RouterSettings(adapters=adapters))
+ batch = [
+ {
+ "problem_type": "tracking",
+ "ground_truth": "one",
+ "response": "first",
+ },
+ {
+ "problem_type": "video_qa_mc",
+ "ground_truth": "A",
+ "response": "second",
+ },
+ {
+ "problem_type": "object_counting",
+ "ground_truth": "2",
+ "response": "third",
+ },
+ {
+ "problem_type": "object tracking",
+ "ground_truth": "four",
+ "response": "fourth",
+ },
+ ]
+
+ scores = router.compute_score(batch)
+
+ assert [score["overall"] for score in scores] == [
+ adapters[TaskFamily.TRACKING].value,
+ adapters[TaskFamily.VIDEO_QA].value,
+ adapters[TaskFamily.SPATIAL_INTELLIGENCE].value,
+ adapters[TaskFamily.TRACKING].value,
+ ]
+ tracking_batches = adapters[TaskFamily.TRACKING].scored_batches
+ assert len(tracking_batches) == 1
+ assert [item["response"] for item in tracking_batches[0]] == [
+ "first",
+ "fourth",
+ ]
+
+
+def test_unknown_task_raises_instead_of_returning_zero():
+ router = RewardRouter(RouterSettings(adapters=fake_adapters()))
+
+ with pytest.raises(UnknownTaskError, match="Could not route reward sample"):
+ router.compute_score(
+ [
+ {
+ "problem_type": "unsupported-new-task",
+ "ground_truth": "x",
+ "response": "x",
+ }
+ ]
+ )
+
+
+def test_builtin_module_import_is_lazy_and_monkeypatchable(monkeypatch):
+ adapter = FakeAdapter(TaskFamily.TRACKING, 0.75)
+ imported: list[str] = []
+
+ def fake_import(module_path):
+ imported.append(module_path)
+ return adapter
+
+ monkeypatch.setattr(router_module.importlib, "import_module", fake_import)
+ router = RewardRouter()
+ assert imported == []
+
+ score = router.compute_reward(
+ {"problem_type": "tracking", "ground_truth": "gt"},
+ "prediction",
+ )
+
+ assert score["overall"] == 0.75
+ assert imported == ["orarl.rewards.adapters.tracking"]
+
+
+def test_video_qa_module_path_override_is_honored():
+ adapter = FakeAdapter(TaskFamily.VIDEO_QA, 0.65)
+ imported: list[str] = []
+
+ def fake_import(module_path):
+ imported.append(module_path)
+ return adapter
+
+ router = RewardRouter(
+ RouterSettings(module_paths={TaskFamily.VIDEO_QA: "custom.video_reward"}),
+ importer=fake_import,
+ )
+
+ score = router.compute_reward(
+ {"problem_type": "video_qa_mc", "ground_truth": "X"},
+ "custom response",
+ )
+
+ assert score["overall"] == 0.65
+ assert imported == ["custom.video_reward"]
+
+
+def test_video_qa_settings_remain_configurable():
+ router = RewardRouter(
+ RouterSettings(
+ video_qa_options="XY",
+ video_qa_require_answer_tags=False,
+ )
+ )
+
+ score = router.compute_reward(
+ {"problem_type": "video_qa_mc", "ground_truth": "X"},
+ "x",
+ )
+
+ assert score == {"overall": 1.0, "accuracy": 1.0, "format": 1.0}
+
+
+def test_oracle_builder_receives_ground_truth_and_sample_extra():
+ adapters = fake_adapters()
+ router = RewardRouter(RouterSettings(adapters=adapters))
+ sample = {
+ "problem_type": "tracking",
+ "ground_truth": "tracked object",
+ "sample_id": 17,
+ }
+
+ response = router.build_oracle_response(sample)
+
+ assert response == "tracking:tracked object"
+ ground_truth, extra = adapters[TaskFamily.TRACKING].oracle_calls[0]
+ assert ground_truth == "tracked object"
+ assert extra["sample_id"] == 17
+
+
+def test_trainer_builder_uses_explicit_task_metadata():
+ adapters = fake_adapters()
+ router = RewardRouter(RouterSettings(adapters=adapters))
+
+ response = router.build_oracle_response_from_ground_truth(
+ "7 to 11",
+ {"problem_type": "temporal grounding"},
+ )
+
+ assert response == "temporal_grounding:7 to 11"
+
+
+@pytest.mark.parametrize(
+ ("ground_truth", "expected"),
+ [
+ (
+ '{"positive_points": [[1, 1]], "boxes": [0, 0, 2, 2]}',
+ TaskFamily.SEGMENTATION,
+ ),
+ (
+ '{"time": [1, 2], "boxes": {"1": [0, 0, 2, 2]}}',
+ TaskFamily.SPATIAL_TEMPORAL_GROUNDING,
+ ),
+ ('{"boxes": {"1": [0, 0, 2, 2]}}', TaskFamily.TRACKING),
+ ('{"bbox_2d": [0, 0, 2, 2]}', TaskFamily.SPATIAL_GROUNDING),
+ ("1.5 to 2.5 ", TaskFamily.TEMPORAL_GROUNDING),
+ ("12", TaskFamily.SPATIAL_INTELLIGENCE),
+ ("H ", TaskFamily.VIDEO_QA),
+ ],
+)
+def test_trainer_builder_infers_supported_ground_truth_shapes(
+ ground_truth,
+ expected,
+):
+ adapters = fake_adapters()
+ router = RewardRouter(RouterSettings(adapters=adapters))
+
+ response = router.build_oracle_response_from_ground_truth(ground_truth)
+
+ assert response == f"{expected.value}:{ground_truth}"
+
+
+@pytest.mark.parametrize("option", list("ABCDEFGH"))
+def test_video_qa_exact_option_matching_supports_a_through_h(option):
+ sample = {
+ "problem_type": "video_qa_mc",
+ "ground_truth": f"{option} ",
+ }
+
+ score = compute_reward(sample, f"{option.lower()} ")
+
+ assert score == {"overall": 1.0, "accuracy": 1.0, "format": 1.0}
+
+
+def test_video_qa_rejects_non_exact_option_content_and_raw_response():
+ sample = {
+ "problem_type": "multiple choice",
+ "ground_truth": "H ",
+ }
+
+ verbose = compute_reward(sample, "H because it is visible ")
+ raw = compute_reward(sample, "H")
+ wrong = compute_reward(sample, "A ")
+
+ assert verbose["overall"] == 0.0
+ assert verbose["format"] == 0.0
+ assert raw["format"] == 0.0
+ assert wrong == {"overall": 0.0, "accuracy": 0.0, "format": 1.0}
+
+
+def test_video_qa_preserves_tagged_answer_after_reasoning():
+ sample = {
+ "problem_type": "multiple choice",
+ "ground_truth": "G ",
+ }
+
+ score = compute_reward(
+ sample,
+ "I compare the listed events. G ",
+ )
+
+ assert score == {"overall": 1.0, "accuracy": 1.0, "format": 1.0}
+
+
+def test_video_qa_oracle_builder_normalizes_tags():
+ assert (
+ build_oracle_response_from_ground_truth(
+ "h ",
+ {"problem_type": "video qa"},
+ )
+ == "H "
+ )
+
+
+def test_adapter_contract_requires_an_overall_metric():
+ class InvalidAdapter:
+ @staticmethod
+ def compute_score(batch, **kwargs):
+ del batch, kwargs
+ return [{"format": 1.0}]
+
+ router = RewardRouter(RouterSettings(adapters={TaskFamily.TRACKING: InvalidAdapter()}))
+
+ with pytest.raises(RewardContractError, match="missing 'overall'"):
+ router.compute_reward(
+ {"problem_type": "tracking", "ground_truth": "gt"},
+ "response",
+ )
+
+
+def test_explicit_per_task_score_settings_are_forwarded():
+ adapters = fake_adapters()
+ router = RewardRouter(
+ RouterSettings(
+ adapters=adapters,
+ score_kwargs={TaskFamily.TRACKING: {"format_weight": 0.2}},
+ )
+ )
+
+ router.compute_score(
+ [
+ {
+ "problem_type": "tracking",
+ "ground_truth": "gt",
+ "response": "response",
+ }
+ ],
+ debug=False,
+ )
+
+ assert adapters[TaskFamily.TRACKING].score_options == [{"format_weight": 0.2, "debug": False}]
diff --git a/code/tests/test_runtime_reward_entrypoint.py b/code/tests/test_runtime_reward_entrypoint.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e85422f6a575573a94485aa694cf0822d8de581
--- /dev/null
+++ b/code/tests/test_runtime_reward_entrypoint.py
@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+import importlib.util
+import sys
+import types
+from dataclasses import fields, is_dataclass
+from pathlib import Path
+
+import yaml
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+RUNTIME_ROOT = RELEASE_ROOT / "verl"
+sys.path.insert(0, str(RELEASE_ROOT))
+
+
+def _package(name: str, path: Path) -> types.ModuleType:
+ module = types.ModuleType(name)
+ module.__path__ = [str(path)] # type: ignore[attr-defined]
+ return module
+
+
+def _load_module(
+ name: str,
+ path: Path,
+ monkeypatch,
+) -> types.ModuleType:
+ spec = importlib.util.spec_from_file_location(name, path)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ monkeypatch.setitem(sys.modules, name, module)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_runtime_loads_public_reward_module(monkeypatch) -> None:
+ """The public module:function config must survive runtime initialization."""
+
+ verl_root = RUNTIME_ROOT
+ reward_root = verl_root / "workers" / "reward"
+ monkeypatch.setitem(sys.modules, "verl", _package("verl", verl_root))
+ monkeypatch.setitem(
+ sys.modules,
+ "verl.workers",
+ _package("verl.workers", verl_root / "workers"),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "verl.workers.reward",
+ _package("verl.workers.reward", reward_root),
+ )
+
+ functional = types.ModuleType("verl.utils.py_functional")
+ functional.get_abs_path = lambda value, **_: str(Path(value).resolve())
+ monkeypatch.setitem(
+ sys.modules,
+ "verl.utils",
+ _package("verl.utils", verl_root / "utils"),
+ )
+ monkeypatch.setitem(sys.modules, "verl.utils.py_functional", functional)
+
+ protocol = types.ModuleType("verl.protocol")
+ protocol.DataProto = object
+ monkeypatch.setitem(sys.modules, "verl.protocol", protocol)
+
+ torch = types.ModuleType("torch")
+ torch.Tensor = object
+ monkeypatch.setitem(sys.modules, "torch", torch)
+
+ transformers = types.ModuleType("transformers")
+ transformers.PreTrainedTokenizer = object
+ monkeypatch.setitem(sys.modules, "transformers", transformers)
+
+ config_module = _load_module(
+ "verl.workers.reward.config",
+ reward_root / "config.py",
+ monkeypatch,
+ )
+ function_module = _load_module(
+ "verl.workers.reward.function",
+ reward_root / "function.py",
+ monkeypatch,
+ )
+
+ config = config_module.RewardConfig(
+ reward_function="orarl.rewards:compute_score",
+ )
+ config.post_init()
+ assert config.reward_function == "orarl.rewards"
+ assert config.reward_function_name == "compute_score"
+ assert config.reward_function_is_module is True
+
+ manager = function_module.AutoRewardManager(config, tokenizer=object())
+ scores = manager.reward_fn(
+ [
+ {
+ "problem_type": "video_qa_mc",
+ "ground_truth": "A ",
+ "response": "A ",
+ }
+ ]
+ )
+ assert scores[0]["overall"] == 1.0
+
+
+def _assert_config_keys(config: object, payload: dict, path: str = "") -> None:
+ known = {item.name for item in fields(config)}
+ unknown = sorted(set(payload) - known)
+ assert not unknown, f"{path or ''} has unknown fields: {unknown}"
+ for key, value in payload.items():
+ child = getattr(config, key)
+ if isinstance(value, dict) and is_dataclass(child):
+ _assert_config_keys(child, value, f"{path}.{key}".strip("."))
+
+
+def test_public_recipes_match_runtime_dataclasses(monkeypatch) -> None:
+ """Every public YAML field must be accepted by the bundled runtime."""
+
+ verl_root = RUNTIME_ROOT
+ reward_root = verl_root / "workers" / "reward"
+ monkeypatch.setitem(sys.modules, "verl", _package("verl", verl_root))
+ monkeypatch.setitem(
+ sys.modules,
+ "verl.trainer",
+ _package("verl.trainer", verl_root / "trainer"),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "verl.workers",
+ _package("verl.workers", verl_root / "workers"),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "verl.workers.reward",
+ _package("verl.workers.reward", reward_root),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "verl.utils",
+ _package("verl.utils", verl_root / "utils"),
+ )
+
+ functional = types.ModuleType("verl.utils.py_functional")
+ functional.get_abs_path = lambda value, **_: str(Path(value).resolve())
+ monkeypatch.setitem(sys.modules, "verl.utils.py_functional", functional)
+
+ multimodal_contract = types.ModuleType("verl.utils.multimodal_contract")
+ multimodal_contract.normalize_video_source_mode = lambda value, **_: value
+ monkeypatch.setitem(
+ sys.modules,
+ "verl.utils.multimodal_contract",
+ multimodal_contract,
+ )
+
+ reward_config_module = _load_module(
+ "verl.workers.reward.config",
+ reward_root / "config.py",
+ monkeypatch,
+ )
+ reward_package = sys.modules["verl.workers.reward"]
+ reward_package.RewardConfig = reward_config_module.RewardConfig
+
+ trainer_config_module = _load_module(
+ "verl.trainer.config",
+ verl_root / "trainer" / "config.py",
+ monkeypatch,
+ )
+ runtime_config = trainer_config_module.PPOConfig()
+ for name in (
+ "grpo_4b.yaml",
+ "grpo_9b.yaml",
+ "orarl_4b.yaml",
+ "orarl_9b.yaml",
+ ):
+ payload = yaml.safe_load((RELEASE_ROOT / "configs" / name).read_text(encoding="utf-8"))
+ _assert_config_keys(runtime_config, payload)
diff --git a/code/tests/test_training_jsonl_dataset.py b/code/tests/test_training_jsonl_dataset.py
new file mode 100644
index 0000000000000000000000000000000000000000..819c882b37ce9cbc30bd9e42731e1b46fae130c2
--- /dev/null
+++ b/code/tests/test_training_jsonl_dataset.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+import json
+import pickle
+
+import pytest
+
+pytest.importorskip("torch")
+pytest.importorskip("qwen_vl_utils")
+
+from verl.utils.dataset import ( # noqa: E402
+ LocalJsonlDataset,
+ _align_media_placeholders,
+)
+
+
+def test_local_jsonl_preserves_task_specific_columns(tmp_path) -> None:
+ path = tmp_path / "mixed.jsonl"
+ rows = [
+ {
+ "problem": "temporal prompt",
+ "answer": "1 to 2 ",
+ "problem_type": "temporal grounding",
+ "videos": ["/media/a.mp4"],
+ "pred_span": [1.0, 2.0],
+ },
+ {
+ "problem": "segmentation prompt",
+ "answer": "{} ",
+ "problem_type": "segmentation",
+ "videos": ["/media/b.mp4"],
+ "segmentation_output": {"object_id": 1},
+ },
+ ]
+ path.write_text(
+ "".join(json.dumps(row) + "\n" for row in rows),
+ encoding="utf-8",
+ )
+
+ dataset = LocalJsonlDataset(str(path))
+
+ assert len(dataset) == 2
+ assert dataset[0]["pred_span"] == [1.0, 2.0]
+ assert dataset[1]["segmentation_output"] == {"object_id": 1}
+ assert "segmentation_output" not in dataset[0]
+
+
+def test_local_jsonl_filter_and_pickle_keep_random_access(tmp_path) -> None:
+ path = tmp_path / "mixed.jsonl"
+ path.write_text(
+ "\n".join(
+ [
+ json.dumps({"problem_type": "tracking", "value": 1}),
+ "",
+ json.dumps({"problem_type": "video_qa_mc", "value": 2}),
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ dataset = LocalJsonlDataset(str(path))
+ filtered = dataset.filter(lambda row: row["value"] == 2)
+ restored = pickle.loads(pickle.dumps(filtered))
+
+ assert len(dataset) == 2
+ assert len(restored) == 1
+ assert restored[0] == {"problem_type": "video_qa_mc", "value": 2}
+
+
+def test_surplus_media_placeholders_are_removed() -> None:
+ prompt = " Question with literal tail"
+
+ aligned = _align_media_placeholders(prompt, "", media_count=2)
+
+ assert aligned.count("") == 2
+ assert "Question with" in aligned
+ assert "literal tail" in aligned
+
+
+def test_missing_media_placeholders_are_rejected() -> None:
+ with pytest.raises(ValueError, match="1 placeholder"):
+ _align_media_placeholders(" Question", "", media_count=2)
diff --git a/code/tests/test_training_runtime.py b/code/tests/test_training_runtime.py
new file mode 100644
index 0000000000000000000000000000000000000000..4e95a2a8b7cb0e6cad4ea305b5d88d9ba539448f
--- /dev/null
+++ b/code/tests/test_training_runtime.py
@@ -0,0 +1,84 @@
+"""Guard the bundled trainer against packaging drift and legacy naming."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+from typing import Iterator
+
+import tomllib
+import yaml
+
+RELEASE_ROOT = Path(__file__).resolve().parents[1]
+RUNTIME_ROOT = RELEASE_ROOT / "verl"
+
+# Internal vocabulary that must not resurface in the public runtime.
+FORBIDDEN_RUNTIME_TERMS = (
+ "".join(("c", "p", "p", "o")),
+ "".join(("g", "t", "p", "o")),
+ "".join(("g", "t", "_")),
+ "".join(("lu", "ff", "y")),
+ "easy_r1",
+)
+
+
+def _runtime_sources() -> Iterator[Path]:
+ return (path for path in sorted(RUNTIME_ROOT.rglob("*.py")))
+
+
+def test_runtime_ships_with_the_release() -> None:
+ assert (RUNTIME_ROOT / "trainer" / "main.py").is_file()
+ assert (RUNTIME_ROOT / "trainer" / "ray_trainer.py").is_file()
+ assert (RUNTIME_ROOT / "workers" / "fsdp_workers.py").is_file()
+
+ pyproject = tomllib.loads((RELEASE_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
+ assert "verl*" in pyproject["tool"]["setuptools"]["packages"]["find"]["include"]
+ manifest = (RELEASE_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
+ assert "recursive-include verl *.py" in manifest
+
+
+def test_orarl_recipes_declare_every_oracle_stage() -> None:
+ algorithm_config = ast.parse(
+ (RUNTIME_ROOT / "trainer" / "config.py").read_text(encoding="utf-8")
+ )
+ declared = {
+ statement.target.id
+ for node in ast.walk(algorithm_config)
+ if isinstance(node, ast.ClassDef) and node.name == "AlgorithmConfig"
+ for statement in node.body
+ if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name)
+ }
+ for name in ("orarl_4b.yaml", "orarl_9b.yaml"):
+ payload = yaml.safe_load((RELEASE_ROOT / "configs" / name).read_text(encoding="utf-8"))
+ algorithm = payload["algorithm"]
+ assert algorithm["oracle_builder"].startswith("orarl.rewards:")
+ assert payload["worker"]["reward"]["reward_function"].startswith("orarl.rewards:")
+ for stage in (
+ "oracle_injection",
+ "directional_gain",
+ "detached_oracle_advantage",
+ "selection_prune_ratio",
+ "post_selection_recenter",
+ ):
+ assert stage in algorithm
+ assert stage in declared
+
+
+def test_runtime_uses_the_public_vocabulary() -> None:
+ offenders: list[str] = []
+ for path in _runtime_sources():
+ lowered = path.read_text(encoding="utf-8").casefold()
+ for term in FORBIDDEN_RUNTIME_TERMS:
+ if term in lowered:
+ offenders.append(f"{path.relative_to(RELEASE_ROOT)}: {term}")
+ assert not offenders, offenders
+
+
+def test_trainer_entry_point_is_the_bundled_module() -> None:
+ launcher = (RELEASE_ROOT / "orarl" / "cli" / "train.py").read_text(encoding="utf-8")
+ assert '"verl.trainer.main"' in launcher
+
+ main_source = (RUNTIME_ROOT / "trainer" / "main.py").read_text(encoding="utf-8")
+ tree = ast.parse(main_source)
+ functions = {node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)}
+ assert "main" in functions
diff --git a/code/verl/__init__.py b/code/verl/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb0cd6c4de9be1eae813dda982eae3c732b56156
--- /dev/null
+++ b/code/verl/__init__.py
@@ -0,0 +1,32 @@
+# Copyright 2024 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.
+
+import os
+
+from .utils.py_functional import is_package_available
+
+
+if is_package_available("modelscope"):
+ from modelscope.utils.hf_util import patch_hub # type: ignore
+
+
+__version__ = "0.3.3.dev0"
+
+
+if os.getenv("USE_MODELSCOPE_HUB", "0").lower() in ["true", "y", "1"]:
+ # Patch hub to download models from modelscope to speed up.
+ if not is_package_available("modelscope"):
+ raise ImportError("You are using the modelscope hub, please install modelscope by `pip install modelscope`.")
+
+ patch_hub()
diff --git a/code/verl/models/__init__.py b/code/verl/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ce90c5eb352d85c59105c0dc85b5f1dd576f095
--- /dev/null
+++ b/code/verl/models/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2024 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.
diff --git a/code/verl/models/monkey_patch.py b/code/verl/models/monkey_patch.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc6bccd748c182b8280b1c271aba7c8cca1f2d82
--- /dev/null
+++ b/code/verl/models/monkey_patch.py
@@ -0,0 +1,89 @@
+# Copyright 2024 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 transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
+
+from ..utils.py_functional import is_transformers_version_greater_than
+from .transformers.flash_attention_utils import flash_attention_forward
+
+
+SUPPORTED_MODEL_TYPE = (
+ "llama",
+ "gemma",
+ "gemma2",
+ "mistral",
+ "qwen2",
+ "qwen2_moe",
+ "qwen3",
+ "qwen3_moe",
+ "qwen2_vl",
+ "qwen2_5_vl",
+ "qwen3_vl",
+ "qwen3_vl_moe",
+ "qwen3_5",
+)
+
+QWEN2_VL_MODELS = ("qwen2_vl", "qwen2_5_vl")
+QWEN3_VL_MODELS = ("qwen3_vl", "qwen3_vl_moe")
+QWEN3_5_MODELS = ("qwen3_5",)
+
+
+def apply_ulysses_patch(model_type: str) -> None:
+ if not is_transformers_version_greater_than("4.54.0"):
+ raise RuntimeError("Only support transformers >= 4.54.0.")
+
+ if model_type in SUPPORTED_MODEL_TYPE:
+ ALL_ATTENTION_FUNCTIONS["flash_attention_2"] = flash_attention_forward
+ else:
+ raise NotImplementedError(f"Model architecture {model_type} is not supported yet.")
+
+ if model_type in QWEN2_VL_MODELS:
+ from transformers.models.qwen2_5_vl.modeling_qwen2_5_vl import (
+ Qwen2_5_VLForConditionalGeneration,
+ Qwen2_5_VLModel,
+ )
+ from transformers.models.qwen2_vl.modeling_qwen2_vl import Qwen2VLForConditionalGeneration, Qwen2VLModel
+
+ from .transformers.qwen2_vl import qwen2_vl_base_forward, qwen2_vl_model_forward
+
+ # fix text-image mixed data
+ Qwen2VLModel.forward = qwen2_vl_base_forward
+ Qwen2_5_VLModel.forward = qwen2_vl_base_forward
+ # TODO: add linear cross entropy kernels
+ Qwen2VLForConditionalGeneration.forward = qwen2_vl_model_forward
+ Qwen2_5_VLForConditionalGeneration.forward = qwen2_vl_model_forward
+ elif model_type in QWEN3_VL_MODELS:
+ from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLForConditionalGeneration, Qwen3VLModel
+ from transformers.models.qwen3_vl_moe.modeling_qwen3_vl_moe import (
+ Qwen3VLMoeForConditionalGeneration,
+ Qwen3VLMoeModel,
+ )
+
+ from .transformers.qwen3_vl import qwen3_vl_base_forward, qwen3_vl_model_forward
+
+ # fix text-image mixed data
+ Qwen3VLModel.forward = qwen3_vl_base_forward
+ Qwen3VLMoeModel.forward = qwen3_vl_base_forward
+ # TODO: add linear cross entropy kernels
+ Qwen3VLForConditionalGeneration.forward = qwen3_vl_model_forward
+ Qwen3VLMoeForConditionalGeneration.forward = qwen3_vl_model_forward
+ elif model_type in QWEN3_5_MODELS:
+ from transformers.models.qwen3_5.modeling_qwen3_5 import Qwen3_5ForConditionalGeneration, Qwen3_5Model
+
+ from .transformers.qwen3_5 import qwen3_5_base_forward, qwen3_5_model_forward
+
+ # fix text-image mixed data
+ Qwen3_5Model.forward = qwen3_5_base_forward
+ # TODO: add linear cross entropy kernels
+ Qwen3_5ForConditionalGeneration.forward = qwen3_5_model_forward
diff --git a/code/verl/models/transformers/__init__.py b/code/verl/models/transformers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ce90c5eb352d85c59105c0dc85b5f1dd576f095
--- /dev/null
+++ b/code/verl/models/transformers/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2024 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.
diff --git a/code/verl/models/transformers/flash_attention_utils.py b/code/verl/models/transformers/flash_attention_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6ad6d35c857ce54e1ee2a9a63d0558d251827fd
--- /dev/null
+++ b/code/verl/models/transformers/flash_attention_utils.py
@@ -0,0 +1,193 @@
+# Copyright 2024 The Fairseq Authors and the HuggingFace Inc. team
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Based on https://github.com/huggingface/transformers/blob/v4.49.0/src/transformers/modeling_flash_attention_utils.py
+#
+# 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.
+
+import inspect
+import os
+from typing import Optional, Tuple
+
+import torch
+import torch.distributed as dist
+from transformers.modeling_flash_attention_utils import _flash_attention_forward, fa_peft_integration_check
+from transformers.utils import is_flash_attn_2_available, is_flash_attn_greater_or_equal_2_10
+
+from ...utils.ulysses import (
+ gather_heads_scatter_seq,
+ gather_seq_scatter_heads,
+ get_ulysses_sequence_parallel_group,
+ get_ulysses_sequence_parallel_world_size,
+)
+
+
+if is_flash_attn_2_available():
+ from flash_attn import flash_attn_func, flash_attn_varlen_func
+
+ _flash_supports_window_size = "window_size" in inspect.signature(flash_attn_func).parameters
+ _flash_supports_deterministic = "deterministic" in inspect.signature(flash_attn_func).parameters
+ _flash_deterministic_enabled = os.getenv("FLASH_ATTENTION_DETERMINISTIC", "0") == "1"
+ _flash_use_top_left_mask = not is_flash_attn_greater_or_equal_2_10()
+
+
+def prepare_fa2_from_position_ids(
+ query: torch.Tensor, key: torch.Tensor, value: torch.Tensor, position_ids: torch.Tensor
+):
+ if position_ids.ndim == 3:
+ position_ids = position_ids[0]
+ assert position_ids.ndim == 2 # (batch_size, seq_length)
+ query = query.contiguous().view(-1, query.size(-2), query.size(-1))
+ key = key.contiguous().view(-1, key.size(-2), key.size(-1))
+ value = value.contiguous().view(-1, value.size(-2), value.size(-1))
+ tensor_kwargs = {"dtype": torch.int32, "device": position_ids.device}
+ position_ids = position_ids.view(-1)
+ cu_seqlens = torch.cat(
+ (
+ (position_ids == 0).nonzero().view(-1).to(**tensor_kwargs),
+ torch.tensor(position_ids.size(), **tensor_kwargs),
+ )
+ )
+ max_length = cu_seqlens.diff().max() # use cu_seqlens to infer max_length for qwen2vl mrope
+ return (query, key, value, (cu_seqlens, cu_seqlens), (max_length, max_length))
+
+
+def _custom_flash_attention_forward(
+ query_states: torch.Tensor,
+ key_states: torch.Tensor,
+ value_states: torch.Tensor,
+ attention_mask: Optional[torch.Tensor],
+ query_length: int,
+ is_causal: bool = True,
+ position_ids: Optional[torch.Tensor] = None,
+ sliding_window: Optional[int] = None,
+ use_top_left_mask: bool = False,
+ deterministic: Optional[bool] = None,
+ **kwargs,
+):
+ """
+ Patches flash attention forward to handle 3D position ids in mrope. (3, batch_size, seq_length)
+ """
+ # Assuming 4D tensors, key_states.shape[1] is the key/value sequence length (source length).
+ use_sliding_windows = (
+ _flash_supports_window_size and sliding_window is not None and key_states.shape[1] > sliding_window
+ )
+ flash_kwargs = {"window_size": (sliding_window, sliding_window)} if use_sliding_windows else {}
+
+ if _flash_supports_deterministic:
+ flash_kwargs["deterministic"] = deterministic if deterministic is not None else _flash_deterministic_enabled
+
+ if kwargs.get("softcap") is not None:
+ flash_kwargs["softcap"] = kwargs.pop("softcap")
+
+ query_states, key_states, value_states = fa_peft_integration_check(
+ query_states, key_states, value_states, target_dtype=torch.bfloat16
+ )
+
+ sp_size = get_ulysses_sequence_parallel_world_size()
+ if sp_size > 1:
+ # qkv: (batch_size, seq_length / sp_size, num_head, head_size)
+ query_states = gather_seq_scatter_heads(query_states, seq_dim=1, head_dim=2)
+ key_states = gather_seq_scatter_heads(key_states, seq_dim=1, head_dim=2)
+ value_states = gather_seq_scatter_heads(value_states, seq_dim=1, head_dim=2)
+ position_ids_lst = [torch.empty_like(position_ids) for _ in range(sp_size)]
+ position_ids = dist.all_gather(position_ids_lst, position_ids, group=get_ulysses_sequence_parallel_group())
+ position_ids = torch.cat(position_ids_lst, dim=-1) # (batch_size, seq_length)
+
+ position_ids_for_check = position_ids[0] if position_ids is not None and position_ids.ndim == 3 else position_ids
+
+ if (
+ position_ids_for_check is not None
+ and query_length != 1
+ and not (torch.diff(position_ids_for_check, dim=-1) >= 0).all()
+ ):
+ batch_size = query_states.size(0)
+ q, k, v, (cu_seqlens_q, cu_seqlens_k), (max_seqlen_q, max_seqlen_k) = prepare_fa2_from_position_ids(
+ query_states, key_states, value_states, position_ids
+ )
+ attn_output = flash_attn_varlen_func(
+ q,
+ k,
+ v,
+ cu_seqlens_q=cu_seqlens_q,
+ cu_seqlens_k=cu_seqlens_k,
+ max_seqlen_q=max_seqlen_q,
+ max_seqlen_k=max_seqlen_k,
+ dropout_p=kwargs.pop("dropout", 0.0),
+ softmax_scale=kwargs.pop("softmax_scale", None),
+ causal=is_causal,
+ **flash_kwargs,
+ )
+ attn_output = attn_output.view(batch_size, -1, attn_output.size(-2), attn_output.size(-1))
+ else:
+ attn_output = _flash_attention_forward(
+ query_states,
+ key_states,
+ value_states,
+ attention_mask,
+ query_length,
+ is_causal=is_causal,
+ position_ids=position_ids,
+ sliding_window=sliding_window,
+ use_top_left_mask=use_top_left_mask,
+ deterministic=deterministic,
+ **kwargs,
+ )
+
+ if sp_size > 1:
+ # output: (batch_size, seq_length / sp_size, num_head, head_size)
+ attn_output = gather_heads_scatter_seq(attn_output, head_dim=2, seq_dim=1)
+
+ return attn_output
+
+
+def flash_attention_forward(
+ module: torch.nn.Module,
+ query: torch.Tensor,
+ key: torch.Tensor,
+ value: torch.Tensor,
+ attention_mask: Optional[torch.Tensor],
+ dropout: float = 0.0,
+ scaling: Optional[float] = None,
+ sliding_window: Optional[int] = None,
+ softcap: Optional[float] = None,
+ **kwargs,
+) -> Tuple[torch.Tensor, None]:
+ # This is before the transpose
+ q_len = query.shape[2]
+
+ # FA2 uses non-transposed inputs
+ query = query.transpose(1, 2)
+ key = key.transpose(1, 2)
+ value = value.transpose(1, 2)
+
+ # FA2 uses the kwargs value if explicitly passed, otherwise it uses the module attribute
+ is_causal = kwargs.pop("is_causal", None)
+ if is_causal is None:
+ is_causal = getattr(module, "is_causal", True)
+
+ attn_output = _custom_flash_attention_forward(
+ query,
+ key,
+ value,
+ attention_mask,
+ query_length=q_len,
+ is_causal=is_causal,
+ dropout=dropout,
+ softmax_scale=scaling,
+ sliding_window=sliding_window,
+ softcap=softcap,
+ use_top_left_mask=_flash_use_top_left_mask,
+ **kwargs,
+ )
+
+ return attn_output, None
diff --git a/code/verl/models/transformers/qwen2_vl.py b/code/verl/models/transformers/qwen2_vl.py
new file mode 100644
index 0000000000000000000000000000000000000000..4767b2088666647cf8df67f1eb53c8fa1333213f
--- /dev/null
+++ b/code/verl/models/transformers/qwen2_vl.py
@@ -0,0 +1,230 @@
+# Copyright 2024 The Qwen team, Alibaba Group and the HuggingFace Inc. team
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Based on:
+# https://github.com/huggingface/transformers/blob/v4.49.0/src/transformers/models/qwen2_vl/modeling_qwen2_vl.py
+#
+# 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 typing import Optional
+
+import torch
+from transformers.models.qwen2_vl.modeling_qwen2_vl import (
+ Qwen2VLCausalLMOutputWithPast,
+ Qwen2VLForConditionalGeneration,
+ Qwen2VLModel,
+ Qwen2VLModelOutputWithPast,
+)
+from transformers.models.qwen2_vl.processing_qwen2_vl import Qwen2VLProcessor
+
+
+def get_rope_index(
+ processor: "Qwen2VLProcessor",
+ input_ids: torch.Tensor,
+ image_grid_thw: Optional[torch.Tensor] = None,
+ video_grid_thw: Optional[torch.Tensor] = None,
+ second_per_grid_ts: Optional[torch.Tensor] = None,
+ attention_mask: Optional[torch.Tensor] = None,
+) -> torch.Tensor:
+ """
+ Gets the position ids for Qwen2-VL, it should be generated before sharding the sequence.
+ The batch dim has been removed and the input_ids should be a 1D tensor representing a single example.
+ https://github.com/huggingface/transformers/blob/v4.52.4/src/transformers/models/qwen2_5_vl/modeling_qwen2_5_vl.py#L1405
+ """
+ spatial_merge_size = processor.image_processor.merge_size
+ tokens_per_second = 2
+ image_token_id = processor.tokenizer.convert_tokens_to_ids("<|image_pad|>")
+ video_token_id = processor.tokenizer.convert_tokens_to_ids("<|video_pad|>")
+ vision_start_token_id = processor.tokenizer.convert_tokens_to_ids("<|vision_start|>")
+ if input_ids is not None and (image_grid_thw is not None or video_grid_thw is not None):
+ if attention_mask is None:
+ attention_mask = torch.ones_like(input_ids)
+
+ position_ids = torch.ones(3, input_ids.size(0), dtype=input_ids.dtype, device=input_ids.device) # (3, seqlen)
+ image_index, video_index = 0, 0
+ input_ids = input_ids[attention_mask == 1]
+ image_nums, video_nums = 0, 0
+ vision_start_indices = torch.argwhere(input_ids == vision_start_token_id)
+ vision_tokens = input_ids[vision_start_indices + 1]
+ image_nums = (vision_tokens == image_token_id).sum()
+ video_nums = (vision_tokens == video_token_id).sum()
+ input_tokens = input_ids.tolist()
+ llm_pos_ids_list: list = []
+ st = 0
+ remain_images, remain_videos = image_nums, video_nums
+ for _ in range(image_nums + video_nums):
+ if image_token_id in input_tokens and remain_images > 0:
+ ed_image = input_tokens.index(image_token_id, st)
+ else:
+ ed_image = len(input_tokens) + 1
+ if video_token_id in input_tokens and remain_videos > 0:
+ ed_video = input_tokens.index(video_token_id, st)
+ else:
+ ed_video = len(input_tokens) + 1
+ if ed_image < ed_video:
+ t, h, w = (
+ image_grid_thw[image_index][0],
+ image_grid_thw[image_index][1],
+ image_grid_thw[image_index][2],
+ )
+ second_per_grid_t = 0
+ image_index += 1
+ remain_images -= 1
+ ed = ed_image
+ else:
+ t, h, w = (
+ video_grid_thw[video_index][0],
+ video_grid_thw[video_index][1],
+ video_grid_thw[video_index][2],
+ )
+ if second_per_grid_ts is not None:
+ second_per_grid_t = second_per_grid_ts[video_index]
+ else:
+ second_per_grid_t = 1.0
+
+ video_index += 1
+ remain_videos -= 1
+ ed = ed_video
+
+ llm_grid_t, llm_grid_h, llm_grid_w = (
+ t.item(),
+ h.item() // spatial_merge_size,
+ w.item() // spatial_merge_size,
+ )
+ text_len = ed - st
+
+ st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
+ llm_pos_ids_list.append(torch.arange(text_len).view(1, -1).expand(3, -1) + st_idx)
+
+ t_index = torch.arange(llm_grid_t).view(-1, 1).expand(-1, llm_grid_h * llm_grid_w)
+ t_index = (t_index * second_per_grid_t * tokens_per_second).long().flatten()
+ h_index = torch.arange(llm_grid_h).view(1, -1, 1).expand(llm_grid_t, -1, llm_grid_w).flatten()
+ w_index = torch.arange(llm_grid_w).view(1, 1, -1).expand(llm_grid_t, llm_grid_h, -1).flatten()
+ llm_pos_ids_list.append(torch.stack([t_index, h_index, w_index]) + text_len + st_idx)
+ st = ed + llm_grid_t * llm_grid_h * llm_grid_w
+
+ if st < len(input_tokens):
+ st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
+ text_len = len(input_tokens) - st
+ llm_pos_ids_list.append(torch.arange(text_len).view(1, -1).expand(3, -1) + st_idx)
+
+ llm_positions = torch.cat(llm_pos_ids_list, dim=1).reshape(3, -1)
+ position_ids[..., attention_mask == 1] = llm_positions.to(position_ids.device)
+ else:
+ if attention_mask is not None:
+ position_ids = attention_mask.long().cumsum(-1) - 1
+ position_ids.masked_fill_(attention_mask == 0, 1)
+ position_ids = position_ids.unsqueeze(0).expand(3, -1).to(input_ids.device)
+ else:
+ position_ids = torch.arange(input_ids.shape[1], device=input_ids.device).view(1, -1).expand(3, -1)
+
+ return position_ids
+
+
+def _get_input_embeds(
+ model: "Qwen2VLModel",
+ input_ids: torch.LongTensor,
+ attention_mask: Optional[torch.Tensor] = None,
+ pixel_values: Optional[torch.FloatTensor] = None,
+ pixel_values_videos: Optional[torch.FloatTensor] = None,
+ image_grid_thw: Optional[torch.LongTensor] = None,
+ video_grid_thw: Optional[torch.LongTensor] = None,
+):
+ inputs_embeds = model.get_input_embeddings()(input_ids)
+ if pixel_values is not None:
+ pixel_values = pixel_values.type(model.visual.dtype)
+ image_embeds = model.visual(pixel_values, grid_thw=image_grid_thw)
+ n_image_tokens = (input_ids == model.config.image_token_id).sum().item()
+ n_image_features = image_embeds.shape[0]
+ if n_image_tokens != n_image_features:
+ raise ValueError(
+ f"Image features and image tokens do not match: tokens: {n_image_tokens}, features {n_image_features}"
+ )
+
+ mask = input_ids == model.config.image_token_id
+ mask_unsqueezed = mask.unsqueeze(-1)
+ mask_expanded = mask_unsqueezed.expand_as(inputs_embeds)
+ image_mask = mask_expanded.to(inputs_embeds.device)
+
+ image_embeds = image_embeds.to(inputs_embeds.device, inputs_embeds.dtype)
+ inputs_embeds = inputs_embeds.masked_scatter(image_mask, image_embeds)
+
+ if pixel_values_videos is not None:
+ pixel_values_videos = pixel_values_videos.type(model.visual.dtype)
+ video_embeds = model.visual(pixel_values_videos, grid_thw=video_grid_thw)
+ n_video_tokens = (input_ids == model.config.video_token_id).sum().item()
+ n_video_features = video_embeds.shape[0]
+ if n_video_tokens != n_video_features:
+ raise ValueError(
+ f"Video features and video tokens do not match: tokens: {n_video_tokens}, features {n_video_features}"
+ )
+
+ mask = input_ids == model.config.video_token_id
+ mask_unsqueezed = mask.unsqueeze(-1)
+ mask_expanded = mask_unsqueezed.expand_as(inputs_embeds)
+ video_mask = mask_expanded.to(inputs_embeds.device)
+
+ video_embeds = video_embeds.to(inputs_embeds.device, inputs_embeds.dtype)
+ inputs_embeds = inputs_embeds.masked_scatter(video_mask, video_embeds)
+
+ if pixel_values is None and pixel_values_videos is None:
+ config = model.config.vision_config
+ patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2
+ pixel_values = torch.zeros((16, patch_dim), dtype=inputs_embeds.dtype, device=inputs_embeds.device)
+ image_grid_thw = torch.tensor([[1, 4, 4]], dtype=torch.long, device=inputs_embeds.device)
+ image_embeds = model.visual(pixel_values, grid_thw=image_grid_thw)
+ inputs_embeds += 0.0 * image_embeds.mean()
+
+ if attention_mask is not None:
+ attention_mask = attention_mask.to(inputs_embeds.device)
+
+ return {
+ "inputs_embeds": inputs_embeds,
+ "attention_mask": attention_mask,
+ }
+
+
+def qwen2_vl_base_forward(
+ self: "Qwen2VLModel",
+ input_ids: torch.LongTensor,
+ attention_mask: Optional[torch.Tensor] = None,
+ pixel_values: Optional[torch.FloatTensor] = None,
+ pixel_values_videos: Optional[torch.FloatTensor] = None,
+ image_grid_thw: Optional[torch.LongTensor] = None,
+ video_grid_thw: Optional[torch.LongTensor] = None,
+ **kwargs,
+):
+ position_ids = kwargs.get("position_ids")
+ if isinstance(position_ids, torch.Tensor) and (position_ids.ndim != 3 or position_ids.size(0) != 4):
+ # we concat the text position ids with the 3D vision position ids by default
+ # see https://github.com/huggingface/transformers/pull/39447
+ raise ValueError("position_ids should be a 3D tensor of shape (4, batch_size, seq_length).")
+
+ input_kwargs = _get_input_embeds(
+ self, input_ids, attention_mask, pixel_values, pixel_values_videos, image_grid_thw, video_grid_thw
+ )
+ kwargs.update(input_kwargs) # avoid lora module to have multiple keyword arguments
+ outputs = self.language_model(input_ids=None, **kwargs)
+ return Qwen2VLModelOutputWithPast(last_hidden_state=outputs.last_hidden_state)
+
+
+def qwen2_vl_model_forward(
+ self: "Qwen2VLForConditionalGeneration",
+ input_ids: torch.LongTensor,
+ labels: Optional[torch.LongTensor] = None,
+ **kwargs,
+) -> "Qwen2VLCausalLMOutputWithPast":
+ outputs = self.model(input_ids=input_ids, **kwargs)
+ hidden_states = outputs[0]
+ logits = self.lm_head(hidden_states)
+
+ return Qwen2VLCausalLMOutputWithPast(logits=logits)
diff --git a/code/verl/models/transformers/qwen3_5.py b/code/verl/models/transformers/qwen3_5.py
new file mode 100644
index 0000000000000000000000000000000000000000..7de5db0f4454c1898ed615c983afda73328e1c0d
--- /dev/null
+++ b/code/verl/models/transformers/qwen3_5.py
@@ -0,0 +1,313 @@
+# Copyright 2024 The Qwen team, Alibaba Group and the HuggingFace Inc. team
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Based on:
+# https://github.com/huggingface/transformers/blob/v5.3.0/src/transformers/models/qwen3_5/modeling_qwen3_5.py
+#
+# 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.
+
+import itertools
+from typing import Optional
+
+import torch
+from transformers.models.qwen3_5.modeling_qwen3_5 import (
+ Qwen3_5CausalLMOutputWithPast,
+ Qwen3_5ForConditionalGeneration,
+ Qwen3_5Model,
+ Qwen3_5ModelOutputWithPast,
+)
+
+
+def get_rope_index(
+ processor,
+ input_ids: torch.Tensor,
+ image_grid_thw: Optional[torch.Tensor] = None,
+ video_grid_thw: Optional[torch.Tensor] = None,
+ attention_mask: Optional[torch.Tensor] = None,
+ **kwargs,
+) -> torch.Tensor:
+ """
+ Gets the position ids for Qwen3.5, it should be generated before sharding the sequence.
+ The batch dim has been removed and the input_ids should be a 1D tensor representing a single example.
+
+ Qwen3.5 uses mm_token_type_ids to distinguish modalities (0=text, 1=image, 2=video)
+ and interleaved MRoPE with sections=[11,11,10].
+
+ Returns position_ids of shape (3, seq_length) — [temporal, height, width].
+ """
+ spatial_merge_size = processor.image_processor.merge_size
+ image_token_id = processor.image_token_id
+ video_token_id = processor.video_token_id
+
+ # Since we use timestamps to separate videos,
+ # like ,
+ # the video_grid_thw should also be split
+ if video_grid_thw is not None:
+ video_grid_thw = torch.repeat_interleave(video_grid_thw, video_grid_thw[:, 0], dim=0)
+ video_grid_thw[:, 0] = 1
+
+ if input_ids is not None and (image_grid_thw is not None or video_grid_thw is not None):
+ if attention_mask is None:
+ attention_mask = torch.ones_like(input_ids)
+
+ position_ids = torch.ones(3, input_ids.shape[0], dtype=input_ids.dtype, device=input_ids.device)
+ attention_mask = attention_mask.to(input_ids.device)
+ input_ids_masked = input_ids[attention_mask == 1]
+
+ # Build mm_token_type_ids: 0=text, 1=image, 2=video
+ mm_token_type_ids = torch.zeros_like(input_ids_masked)
+ mm_token_type_ids[input_ids_masked == image_token_id] = 1
+ mm_token_type_ids[input_ids_masked == video_token_id] = 2
+
+ # Group tokens by modality type using itertools.groupby
+ image_iter = iter(image_grid_thw) if image_grid_thw is not None else iter([])
+ video_iter = iter(video_grid_thw) if video_grid_thw is not None else iter([])
+
+ input_type_group = []
+ for key, group in itertools.groupby(enumerate(mm_token_type_ids.tolist()), lambda x: x[1]):
+ group = list(group)
+ start_index = group[0][0]
+ end_index = group[-1][0] + 1
+ input_type_group.append((key, start_index, end_index))
+
+ current_pos = 0
+ llm_pos_ids_list = []
+ for modality_type, start_idx, end_idx in input_type_group:
+ if modality_type == 0:
+ # Text tokens: standard 1D position IDs expanded to 3D
+ text_len = end_idx - start_idx
+ llm_pos_ids_list.append(
+ torch.arange(text_len, device=input_ids.device).view(1, -1).expand(3, -1) + current_pos
+ )
+ current_pos += text_len
+ else:
+ # Vision tokens (image=1, video=2): compute 3D position IDs
+ if modality_type == 1:
+ grid_thw = next(image_iter)
+ else:
+ grid_thw = next(video_iter)
+
+ # Compute vision position IDs (same logic as Qwen3_5Model.get_vision_position_ids)
+ llm_grid_t = grid_thw[0].item() // 1 # temp_merge_size=1
+ llm_grid_h = grid_thw[1].item() // spatial_merge_size
+ llm_grid_w = grid_thw[2].item() // spatial_merge_size
+
+ image_seq_length = llm_grid_h * llm_grid_w * llm_grid_t
+ position_width = torch.arange(
+ current_pos, current_pos + llm_grid_w, device=input_ids.device
+ ).repeat(llm_grid_h * llm_grid_t)
+ position_height = torch.arange(
+ current_pos, current_pos + llm_grid_h, device=input_ids.device
+ ).repeat_interleave(llm_grid_w * llm_grid_t)
+ position_temporal = torch.full(
+ (image_seq_length,), current_pos, device=input_ids.device, dtype=torch.long
+ )
+ vision_position_ids = torch.stack([position_temporal, position_height, position_width], dim=0)
+ llm_pos_ids_list.append(vision_position_ids)
+
+ # Advance current_pos by max(h, w) // spatial_merge_size (Qwen3.5 convention)
+ current_pos += max(grid_thw[1].item(), grid_thw[2].item()) // spatial_merge_size
+
+ llm_positions = torch.cat(llm_pos_ids_list, dim=1).reshape(3, -1)
+ position_ids[..., attention_mask == 1] = llm_positions.to(position_ids.device)
+ else:
+ if attention_mask is not None:
+ position_ids = attention_mask.long().cumsum(-1) - 1
+ position_ids.masked_fill_(attention_mask == 0, 1)
+ position_ids = position_ids.unsqueeze(0).expand(3, -1).to(attention_mask.device)
+ else:
+ position_ids = torch.arange(input_ids.shape[0], device=input_ids.device).view(1, -1).expand(3, -1)
+
+ return position_ids
+
+
+def _get_input_embeds(
+ model: "Qwen3_5Model",
+ input_ids: torch.LongTensor,
+ attention_mask: Optional[torch.Tensor] = None,
+ pixel_values: Optional[torch.FloatTensor] = None,
+ pixel_values_videos: Optional[torch.FloatTensor] = None,
+ image_grid_thw: Optional[torch.LongTensor] = None,
+ video_grid_thw: Optional[torch.LongTensor] = None,
+):
+ """
+ Compute input embeddings with visual features injected for Qwen3.5.
+
+ Qwen3.5 uses model.visual (Qwen3_5VisionModel) which returns
+ BaseModelOutputWithPooling with pooler_output containing split image embeds.
+ No deepstack_visual_indexes in Qwen3.5-2B (empty list).
+ """
+ inputs_embeds = model.get_input_embeddings()(input_ids)
+
+ if pixel_values is not None:
+ pixel_values = pixel_values.type(model.visual.dtype)
+ image_outputs = model.visual(pixel_values, grid_thw=image_grid_thw, return_dict=True)
+ image_embeds = image_outputs.pooler_output
+ # pooler_output is already split by image; cat them back
+ split_sizes = (image_grid_thw.prod(-1) // model.visual.spatial_merge_size**2).tolist()
+ image_embeds = torch.split(image_embeds, split_sizes)
+ image_embeds = torch.cat(image_embeds, dim=0)
+
+ n_image_tokens = (input_ids == model.config.image_token_id).sum().item()
+ n_image_features = image_embeds.shape[0]
+ if n_image_tokens != n_image_features:
+ raise ValueError(
+ f"Image features and image tokens do not match: tokens: {n_image_tokens}, features {n_image_features}"
+ )
+
+ mask = input_ids == model.config.image_token_id
+ mask_unsqueezed = mask.unsqueeze(-1)
+ mask_expanded = mask_unsqueezed.expand_as(inputs_embeds)
+ image_mask = mask_expanded.to(inputs_embeds.device)
+
+ image_embeds = image_embeds.to(inputs_embeds.device, inputs_embeds.dtype)
+ inputs_embeds = inputs_embeds.masked_scatter(image_mask, image_embeds)
+ else:
+ image_mask = None
+
+ if pixel_values_videos is not None:
+ pixel_values_videos = pixel_values_videos.type(model.visual.dtype)
+ video_outputs = model.visual(pixel_values_videos, grid_thw=video_grid_thw, return_dict=True)
+ video_embeds = video_outputs.pooler_output
+ split_sizes = (video_grid_thw.prod(-1) // model.visual.spatial_merge_size**2).tolist()
+ video_embeds = torch.split(video_embeds, split_sizes)
+ video_embeds = torch.cat(video_embeds, dim=0)
+
+ n_video_tokens = (input_ids == model.config.video_token_id).sum().item()
+ n_video_features = video_embeds.shape[0]
+ if n_video_tokens != n_video_features:
+ raise ValueError(
+ f"Video features and video tokens do not match: tokens: {n_video_tokens}, features {n_video_features}"
+ )
+
+ mask = input_ids == model.config.video_token_id
+ mask_unsqueezed = mask.unsqueeze(-1)
+ mask_expanded = mask_unsqueezed.expand_as(inputs_embeds)
+ video_mask = mask_expanded.to(inputs_embeds.device)
+
+ video_embeds = video_embeds.to(inputs_embeds.device, inputs_embeds.dtype)
+ inputs_embeds = inputs_embeds.masked_scatter(video_mask, video_embeds)
+ else:
+ video_mask = None
+
+ # Dummy gradient flow: when neither images nor videos are present,
+ # run a dummy forward through the visual encoder to maintain gradient flow
+ if pixel_values is None and pixel_values_videos is None:
+ config = model.config.vision_config
+ patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2
+ dummy_pixels = torch.zeros((16, patch_dim), dtype=inputs_embeds.dtype, device=inputs_embeds.device)
+ dummy_grid_thw = torch.tensor([[1, 4, 4]], dtype=torch.long, device=inputs_embeds.device)
+ dummy_outputs = model.visual(dummy_pixels, grid_thw=dummy_grid_thw, return_dict=True)
+ dummy_embeds = dummy_outputs.pooler_output
+ if isinstance(dummy_embeds, (list, tuple)):
+ for emb in dummy_embeds:
+ inputs_embeds += 0.0 * emb.mean()
+ else:
+ inputs_embeds += 0.0 * dummy_embeds.mean()
+
+ # Dummy gradient flow for image-video mixed training:
+ # when only images present, create dummy video input
+ if pixel_values is not None and pixel_values_videos is None:
+ config = model.config.vision_config
+ patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2
+ _video_grid_thw = video_grid_thw if (video_grid_thw is not None) \
+ else torch.tensor([[2, 4, 4]], dtype=torch.long, device=inputs_embeds.device)
+ _T, _H, _W = _video_grid_thw[0].tolist()
+ _n_tokens = int(_T * _H * _W)
+ _dummy_video_pixels = torch.zeros((_n_tokens, patch_dim),
+ dtype=inputs_embeds.dtype, device=inputs_embeds.device)
+ _video_outputs = model.visual(_dummy_video_pixels, grid_thw=_video_grid_thw, return_dict=True)
+ _video_embeds = _video_outputs.pooler_output
+ if isinstance(_video_embeds, (list, tuple)):
+ for emb in _video_embeds:
+ inputs_embeds = inputs_embeds + 0.0 * emb.mean()
+ else:
+ inputs_embeds = inputs_embeds + 0.0 * _video_embeds.mean()
+
+ # Dummy gradient flow: when only videos present, create dummy image input
+ if pixel_values is None and pixel_values_videos is not None:
+ config = model.config.vision_config
+ patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2
+ _image_grid_thw = image_grid_thw if (image_grid_thw is not None) \
+ else torch.tensor([[1, 4, 4]], dtype=torch.long, device=inputs_embeds.device)
+ _T, _H, _W = _image_grid_thw[0].tolist()
+ _n_tokens = int(_T * _H * _W)
+ _dummy_image_pixels = torch.zeros((_n_tokens, patch_dim),
+ dtype=inputs_embeds.dtype, device=inputs_embeds.device)
+ _image_outputs = model.visual(_dummy_image_pixels, grid_thw=_image_grid_thw, return_dict=True)
+ _image_embeds = _image_outputs.pooler_output
+ if isinstance(_image_embeds, (list, tuple)):
+ for emb in _image_embeds:
+ inputs_embeds = inputs_embeds + 0.0 * emb.mean()
+ else:
+ inputs_embeds = inputs_embeds + 0.0 * _image_embeds.mean()
+
+ if attention_mask is not None:
+ attention_mask = attention_mask.to(inputs_embeds.device)
+
+ return {
+ "inputs_embeds": inputs_embeds,
+ "attention_mask": attention_mask,
+ }
+
+
+def qwen3_5_base_forward(
+ self: "Qwen3_5Model",
+ input_ids: torch.LongTensor,
+ attention_mask: Optional[torch.Tensor] = None,
+ pixel_values: Optional[torch.FloatTensor] = None,
+ pixel_values_videos: Optional[torch.FloatTensor] = None,
+ image_grid_thw: Optional[torch.LongTensor] = None,
+ video_grid_thw: Optional[torch.LongTensor] = None,
+ **kwargs,
+):
+ """
+ Override Qwen3_5Model.forward for RL training with padding-free sequence packing.
+
+ This replaces the original forward to:
+ 1. Handle visual embedding injection with dummy gradient flow
+ 2. Support the verl padding-free training pipeline
+ """
+ position_ids = kwargs.get("position_ids")
+ if isinstance(position_ids, torch.Tensor) and position_ids.ndim not in (2, 3):
+ raise ValueError("position_ids should have shape (batch_size, seq_length) or (3, batch_size, seq_length).")
+
+ input_kwargs = _get_input_embeds(
+ self,
+ input_ids,
+ attention_mask,
+ pixel_values,
+ pixel_values_videos,
+ image_grid_thw,
+ video_grid_thw,
+ )
+ kwargs.update(input_kwargs) # avoid lora module to have multiple keyword arguments
+ outputs = self.language_model(input_ids=None, **kwargs)
+ return Qwen3_5ModelOutputWithPast(last_hidden_state=outputs.last_hidden_state)
+
+
+def qwen3_5_model_forward(
+ self: "Qwen3_5ForConditionalGeneration",
+ input_ids: torch.LongTensor,
+ labels: Optional[torch.LongTensor] = None,
+ **kwargs,
+) -> "Qwen3_5CausalLMOutputWithPast":
+ """
+ Override Qwen3_5ForConditionalGeneration.forward for RL training.
+
+ Calls self.model (Qwen3_5Model) then self.lm_head to get logits.
+ """
+ outputs = self.model(input_ids=input_ids, **kwargs)
+ hidden_states = outputs[0]
+ logits = self.lm_head(hidden_states)
+
+ return Qwen3_5CausalLMOutputWithPast(logits=logits)
diff --git a/code/verl/models/transformers/qwen3_vl.py b/code/verl/models/transformers/qwen3_vl.py
new file mode 100644
index 0000000000000000000000000000000000000000..080afedd500c6f048575f0532638bd144dd31623
--- /dev/null
+++ b/code/verl/models/transformers/qwen3_vl.py
@@ -0,0 +1,393 @@
+# Copyright 2024 The Qwen team, Alibaba Group and the HuggingFace Inc. team
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Based on:
+# https://github.com/huggingface/transformers/blob/v4.49.0/src/transformers/models/qwen2_vl/modeling_qwen2_vl.py
+#
+# 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.
+
+import os
+import time
+from typing import Optional
+
+import torch
+from transformers.models.qwen3_vl.modeling_qwen3_vl import (
+ Qwen3VLCausalLMOutputWithPast,
+ Qwen3VLForConditionalGeneration,
+ Qwen3VLModel,
+ Qwen3VLModelOutputWithPast,
+)
+from transformers.models.qwen3_vl.processing_qwen3_vl import Qwen3VLProcessor
+
+# Rate-limit state for the no-crash visual-alignment guard (see
+# ``_reconcile_visual_features``). Keyed by modality ("image"/"video").
+_VISUAL_GUARD_LAST_LOG: dict[str, float] = {}
+
+
+def _reconcile_visual_features(
+ embeds: torch.Tensor,
+ n_tokens: int,
+ kind: str,
+) -> torch.Tensor:
+ """Trim/pad visual ``embeds`` to exactly ``n_tokens`` rows so the FSDP
+ forward does not crash on an aggregate token/feature mismatch.
+
+ This is a DIAGNOSTIC guard for the no-cache RL pipeline: when enabled
+ (``VERL_GUARD_VIDEO_MISMATCH=1``, the default), a mismatch is logged
+ (rate-limited) and reconciled instead of raising, so a real run keeps
+ collecting the per-sample culprit info emitted by the FSDP worker's
+ ``_diagnose_video_alignment``. Reconciliation slightly corrupts the
+ offending micro-batch's visual grounding, so it is meant for hunting the
+ root cause, not for production training. Set ``VERL_GUARD_VIDEO_MISMATCH=0``
+ to restore the hard ``ValueError``.
+ """
+ n_features = embeds.shape[0]
+ if n_features == n_tokens:
+ return embeds
+
+ if os.environ.get("VERL_GUARD_VIDEO_MISMATCH", "1") != "1":
+ raise ValueError(
+ f"{kind.capitalize()} features and {kind} tokens do not match: "
+ f"tokens: {n_tokens}, features {n_features}"
+ )
+
+ now = time.time()
+ if now - _VISUAL_GUARD_LAST_LOG.get(kind, 0.0) >= 10.0:
+ _VISUAL_GUARD_LAST_LOG[kind] = now
+ print(
+ f"[VIDEO-ALIGN][guard] {kind} tokens={n_tokens} features={n_features} "
+ f"(delta={n_features - n_tokens}); reconciling to avoid crash "
+ f"(set VERL_GUARD_VIDEO_MISMATCH=0 to hard-fail).",
+ flush=True,
+ )
+
+ if n_features > n_tokens:
+ return embeds[:n_tokens]
+ # n_features < n_tokens: pad by repeating the last feature row.
+ pad = embeds[-1:].expand(n_tokens - n_features, *embeds.shape[1:])
+ return torch.cat([embeds, pad], dim=0)
+
+
+def get_rope_index(
+ processor: "Qwen3VLProcessor",
+ input_ids: torch.Tensor,
+ image_grid_thw: Optional[torch.Tensor] = None,
+ video_grid_thw: Optional[torch.Tensor] = None,
+ attention_mask: Optional[torch.Tensor] = None,
+ **kwargs,
+) -> torch.Tensor:
+ """
+ Gets the position ids for Qwen3-VL, it should be generated before sharding the sequence.
+ The batch dim has been removed and the input_ids should be a 1D tensor representing a single example.
+ https://github.com/huggingface/transformers/blob/v4.57.0/src/transformers/models/qwen3_vl/modeling_qwen3_vl.py#L916
+ """
+ spatial_merge_size = processor.image_processor.merge_size
+ image_token_id = processor.image_token_id
+ video_token_id = processor.video_token_id
+ vision_start_token_id = processor.vision_start_token_id
+
+ # Since we use timestamps to seperate videos,
+ # like ,
+ # the video_grid_thw should also be split
+ if video_grid_thw is not None:
+ video_grid_thw = torch.repeat_interleave(video_grid_thw, video_grid_thw[:, 0], dim=0)
+ video_grid_thw[:, 0] = 1
+
+ if input_ids is not None and (image_grid_thw is not None or video_grid_thw is not None):
+ if attention_mask is None:
+ attention_mask = torch.ones_like(input_ids)
+
+ position_ids = torch.ones(3, input_ids.shape[0], dtype=input_ids.dtype, device=input_ids.device)
+ image_index, video_index = 0, 0
+ attention_mask = attention_mask.to(input_ids.device)
+ input_ids = input_ids[attention_mask == 1]
+ image_nums, video_nums = 0, 0
+ vision_start_indices = torch.argwhere(input_ids == vision_start_token_id)
+ vision_tokens = input_ids[vision_start_indices + 1]
+ image_nums = (vision_tokens == image_token_id).sum()
+ video_nums = (vision_tokens == video_token_id).sum()
+ input_tokens = input_ids.tolist()
+ llm_pos_ids_list: list = []
+ st = 0
+ remain_images, remain_videos = image_nums, video_nums
+ for _ in range(image_nums + video_nums):
+ if image_token_id in input_tokens and remain_images > 0:
+ ed_image = input_tokens.index(image_token_id, st)
+ else:
+ ed_image = len(input_tokens) + 1
+ if video_token_id in input_tokens and remain_videos > 0:
+ ed_video = input_tokens.index(video_token_id, st)
+ else:
+ ed_video = len(input_tokens) + 1
+ if ed_image < ed_video:
+ t, h, w = (
+ image_grid_thw[image_index][0],
+ image_grid_thw[image_index][1],
+ image_grid_thw[image_index][2],
+ )
+ image_index += 1
+ remain_images -= 1
+ ed = ed_image
+ else:
+ t, h, w = (
+ video_grid_thw[video_index][0],
+ video_grid_thw[video_index][1],
+ video_grid_thw[video_index][2],
+ )
+ video_index += 1
+ remain_videos -= 1
+ ed = ed_video
+
+ llm_grid_t, llm_grid_h, llm_grid_w = (
+ t.item(),
+ h.item() // spatial_merge_size,
+ w.item() // spatial_merge_size,
+ )
+ text_len = ed - st
+
+ st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
+ llm_pos_ids_list.append(torch.arange(text_len).view(1, -1).expand(3, -1) + st_idx)
+
+ # t_index is always 0 because llm_grid_t is always 1 (we use timestamps to encode the temporal information for videos)
+ t_index = torch.arange(llm_grid_t).view(-1, 1).expand(-1, llm_grid_h * llm_grid_w).flatten()
+ h_index = torch.arange(llm_grid_h).view(1, -1, 1).expand(llm_grid_t, -1, llm_grid_w).flatten()
+ w_index = torch.arange(llm_grid_w).view(1, 1, -1).expand(llm_grid_t, llm_grid_h, -1).flatten()
+ llm_pos_ids_list.append(torch.stack([t_index, h_index, w_index]) + text_len + st_idx)
+ st = ed + llm_grid_t * llm_grid_h * llm_grid_w
+
+ if st < len(input_tokens):
+ st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
+ text_len = len(input_tokens) - st
+ llm_pos_ids_list.append(torch.arange(text_len).view(1, -1).expand(3, -1) + st_idx)
+
+ llm_positions = torch.cat(llm_pos_ids_list, dim=1).reshape(3, -1)
+ position_ids[..., attention_mask == 1] = llm_positions.to(position_ids.device)
+ else:
+ if attention_mask is not None:
+ position_ids = attention_mask.long().cumsum(-1) - 1
+ position_ids.masked_fill_(attention_mask == 0, 1)
+ position_ids = position_ids.unsqueeze(0).expand(3, -1).to(attention_mask.device)
+ else:
+ position_ids = torch.arange(input_ids.shape[1], device=input_ids.device).view(1, -1).expand(3, -1)
+
+ return position_ids
+
+
+def _get_input_embeds(
+ model: "Qwen3VLModel",
+ input_ids: torch.LongTensor,
+ attention_mask: Optional[torch.Tensor] = None,
+ pixel_values: Optional[torch.FloatTensor] = None,
+ pixel_values_videos: Optional[torch.FloatTensor] = None,
+ image_grid_thw: Optional[torch.LongTensor] = None,
+ video_grid_thw: Optional[torch.LongTensor] = None,
+):
+ def _visual_forward(pixel_tensor: torch.Tensor, grid_thw: torch.Tensor):
+ """Normalize Qwen3-VL vision outputs across Transformers versions."""
+ try:
+ outputs = model.visual(pixel_tensor, grid_thw=grid_thw, return_dict=True)
+ except TypeError:
+ # Older Qwen3-VL implementations do not accept return_dict and
+ # directly return (pooled_features, deepstack_features).
+ outputs = model.visual(pixel_tensor, grid_thw=grid_thw)
+
+ if hasattr(outputs, "pooler_output"):
+ embeds = outputs.pooler_output
+ deepstack = getattr(outputs, "deepstack_features", None)
+ elif isinstance(outputs, (tuple, list)) and len(outputs) >= 2:
+ embeds, deepstack = outputs[0], outputs[1]
+ else:
+ raise TypeError(
+ "Unsupported Qwen3-VL vision output: expected a model output "
+ "with pooler_output/deepstack_features or a 2-tuple."
+ )
+
+ # get_image_features/get_video_features may return one tensor per
+ # image/video, while a direct visual forward returns one tensor.
+ if isinstance(embeds, (tuple, list)):
+ embeds = torch.cat(tuple(embeds), dim=0)
+ if deepstack is None:
+ deepstack = []
+ return embeds, deepstack
+
+ inputs_embeds = model.get_input_embeddings()(input_ids)
+ image_mask, video_mask = None, None
+ if pixel_values is not None:
+ pixel_values = pixel_values.type(model.visual.dtype)
+ image_embeds, deepstack_image_embeds = _visual_forward(pixel_values, image_grid_thw)
+ n_image_tokens = (input_ids == model.config.image_token_id).sum().item()
+ n_image_features = image_embeds.shape[0]
+ if n_image_tokens != n_image_features:
+ raise ValueError(
+ f"Image features and image tokens do not match: tokens: {n_image_tokens}, features {n_image_features}"
+ )
+
+ mask = input_ids == model.config.image_token_id
+ mask_unsqueezed = mask.unsqueeze(-1)
+ mask_expanded = mask_unsqueezed.expand_as(inputs_embeds)
+ image_mask = mask_expanded.to(inputs_embeds.device)
+
+ image_embeds = image_embeds.to(inputs_embeds.device, inputs_embeds.dtype)
+ inputs_embeds = inputs_embeds.masked_scatter(image_mask, image_embeds)
+
+ if pixel_values_videos is not None:
+ pixel_values_videos = pixel_values_videos.type(model.visual.dtype)
+ video_embeds, deepstack_video_embeds = _visual_forward(pixel_values_videos, video_grid_thw)
+ n_video_tokens = (input_ids == model.config.video_token_id).sum().item()
+ # Guarded reconcile (no-cache RL diagnostic): trim/pad to the token count
+ # instead of raising, so the run survives and the FSDP worker's
+ # per-sample audit keeps naming the offending sample. The deepstack
+ # tensors scatter into the same video positions, so reconcile them too.
+ video_embeds = _reconcile_visual_features(video_embeds, n_video_tokens, "video")
+ deepstack_video_embeds = [
+ _reconcile_visual_features(d, n_video_tokens, "video") for d in deepstack_video_embeds
+ ]
+
+ mask = input_ids == model.config.video_token_id
+ mask_unsqueezed = mask.unsqueeze(-1)
+ mask_expanded = mask_unsqueezed.expand_as(inputs_embeds)
+ video_mask = mask_expanded.to(inputs_embeds.device)
+
+ video_embeds = video_embeds.to(inputs_embeds.device, inputs_embeds.dtype)
+ inputs_embeds = inputs_embeds.masked_scatter(video_mask, video_embeds)
+
+ visual_pos_masks = None
+ deepstack_visual_embeds = None
+ if image_mask is not None and video_mask is not None:
+ # aggregate visual_pos_masks and deepstack_visual_embeds
+ image_mask = image_mask[..., 0]
+ video_mask = video_mask[..., 0]
+ visual_pos_masks = image_mask | video_mask
+ deepstack_visual_embeds = []
+ image_mask_joint = image_mask[visual_pos_masks]
+ video_mask_joint = video_mask[visual_pos_masks]
+ for img_embed, vid_embed in zip(deepstack_image_embeds, deepstack_video_embeds):
+ embed_joint = img_embed.new_zeros(visual_pos_masks.sum(), img_embed.shape[-1]).to(img_embed.device)
+ embed_joint[image_mask_joint, :] = img_embed
+ embed_joint[video_mask_joint, :] = vid_embed
+ deepstack_visual_embeds.append(embed_joint)
+ elif image_mask is not None:
+ image_mask = image_mask[..., 0]
+ visual_pos_masks = image_mask
+ deepstack_visual_embeds = deepstack_image_embeds
+ elif video_mask is not None:
+ video_mask = video_mask[..., 0]
+ visual_pos_masks = video_mask
+ deepstack_visual_embeds = deepstack_video_embeds
+
+ if pixel_values is None and pixel_values_videos is None:
+ config = model.config.vision_config
+ patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2
+ pixel_values = torch.zeros((16, patch_dim), dtype=inputs_embeds.dtype, device=inputs_embeds.device)
+ image_grid_thw = torch.tensor([[1, 4, 4]], dtype=torch.long, device=inputs_embeds.device)
+ image_embeds, dummy_deepstack_image_embeds = _visual_forward(pixel_values, image_grid_thw)
+ inputs_embeds += 0.0 * image_embeds.mean()
+ for emb in dummy_deepstack_image_embeds or []:
+ inputs_embeds += 0.0 * emb.mean()
+
+ # for image-video mixed training
+
+ if pixel_values is not None and pixel_values_videos is None:
+ config = model.config.vision_config
+ patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2
+
+ _video_grid_thw = (
+ video_grid_thw
+ if (video_grid_thw is not None)
+ else torch.tensor([[2, 4, 4]], dtype=torch.long, device=inputs_embeds.device)
+ )
+ _T, _H, _W = _video_grid_thw[0].tolist()
+ _n_tokens = int(_T * _H * _W)
+
+ _dummy_video_pixels = torch.zeros(
+ (_n_tokens, patch_dim), dtype=inputs_embeds.dtype, device=inputs_embeds.device
+ )
+
+ _video_embeds, _video_deepstack = _visual_forward(_dummy_video_pixels, _video_grid_thw)
+ inputs_embeds = inputs_embeds + 0.0 * _video_embeds.mean()
+ for _emb in _video_deepstack or []:
+ inputs_embeds = inputs_embeds + 0.0 * _emb.mean()
+
+ # for image-video mixed training: create dummy image when only video is present
+ if pixel_values is None and pixel_values_videos is not None:
+ config = model.config.vision_config
+ patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2
+
+ _image_grid_thw = (
+ image_grid_thw
+ if (image_grid_thw is not None)
+ else torch.tensor([[1, 4, 4]], dtype=torch.long, device=inputs_embeds.device)
+ )
+ _T, _H, _W = _image_grid_thw[0].tolist()
+ _n_tokens = int(_T * _H * _W)
+
+ _dummy_image_pixels = torch.zeros(
+ (_n_tokens, patch_dim), dtype=inputs_embeds.dtype, device=inputs_embeds.device
+ )
+
+ _image_embeds, _image_deepstack = _visual_forward(_dummy_image_pixels, _image_grid_thw)
+ inputs_embeds = inputs_embeds + 0.0 * _image_embeds.mean()
+ for _emb in _image_deepstack or []:
+ inputs_embeds = inputs_embeds + 0.0 * _emb.mean()
+
+ if attention_mask is not None:
+ attention_mask = attention_mask.to(inputs_embeds.device)
+
+ return {
+ "inputs_embeds": inputs_embeds,
+ "attention_mask": attention_mask,
+ "visual_pos_masks": visual_pos_masks,
+ "deepstack_visual_embeds": deepstack_visual_embeds,
+ }
+
+
+def qwen3_vl_base_forward(
+ self: "Qwen3VLModel",
+ input_ids: torch.LongTensor,
+ attention_mask: Optional[torch.Tensor] = None,
+ pixel_values: Optional[torch.FloatTensor] = None,
+ pixel_values_videos: Optional[torch.FloatTensor] = None,
+ image_grid_thw: Optional[torch.LongTensor] = None,
+ video_grid_thw: Optional[torch.LongTensor] = None,
+ **kwargs,
+):
+ position_ids = kwargs.get("position_ids")
+ if isinstance(position_ids, torch.Tensor) and (position_ids.ndim != 3 or position_ids.size(0) != 4):
+ # we concat the text position ids with the 3D vision position ids by default
+ # see https://github.com/huggingface/transformers/pull/39447
+ raise ValueError("position_ids should be a 3D tensor of shape (4, batch_size, seq_length).")
+
+ input_kwargs = _get_input_embeds(
+ self,
+ input_ids,
+ attention_mask,
+ pixel_values,
+ pixel_values_videos,
+ image_grid_thw,
+ video_grid_thw,
+ )
+ kwargs.update(input_kwargs) # avoid lora module to have multiple keyword arguments
+ outputs = self.language_model(input_ids=None, **kwargs)
+ return Qwen3VLModelOutputWithPast(last_hidden_state=outputs.last_hidden_state)
+
+
+def qwen3_vl_model_forward(
+ self: "Qwen3VLForConditionalGeneration",
+ input_ids: torch.LongTensor,
+ labels: Optional[torch.LongTensor] = None,
+ **kwargs,
+) -> "Qwen3VLCausalLMOutputWithPast":
+ outputs = self.model(input_ids=input_ids, **kwargs)
+ hidden_states = outputs[0]
+ logits = self.lm_head(hidden_states)
+
+ return Qwen3VLCausalLMOutputWithPast(logits=logits)
diff --git a/code/verl/protocol.py b/code/verl/protocol.py
new file mode 100644
index 0000000000000000000000000000000000000000..05cc0a5e7d97d5849e6778803c33e511089b6aea
--- /dev/null
+++ b/code/verl/protocol.py
@@ -0,0 +1,751 @@
+# Copyright 2024 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.
+"""
+Implement base data transfer protocol between any two functions, modules.
+We can subclass Protocol to define more detailed batch info with specific keys
+"""
+
+import copy
+import io
+import pickle
+from collections import defaultdict
+from dataclasses import dataclass, field
+from typing import Any, Callable, Optional, Union
+
+import numpy as np
+import ray
+import torch
+from numpy.typing import NDArray
+from tensordict import TensorDict
+from torch.distributed import ProcessGroup
+from torch.utils.data import DataLoader
+
+from .utils.py_functional import union_two_dict
+
+
+try:
+ import tensordict
+
+ tensordict.set_lazy_legacy(False).set()
+except Exception:
+ pass
+
+
+__all__ = ["DataProto", "union_tensor_dict"]
+
+
+def pad_dataproto_to_divisor(data: "DataProto", size_divisor: int) -> tuple["DataProto", int]:
+ """Pad a DataProto to size divisible by size_divisor
+
+ Args:
+ data (DataProto): the unpadded DataProto
+ size_divisor (int): size divisor
+
+ Returns:
+ data (DataProto): the padded DataProto
+ pad_size (int)
+ """
+ assert isinstance(data, DataProto), "data must be a DataProto"
+ if len(data) % size_divisor != 0:
+ pad_size = size_divisor - len(data) % size_divisor
+ padding_protos = []
+ remaining_pad = pad_size
+ while remaining_pad > 0:
+ take_size = min(remaining_pad, len(data))
+ padding_protos.append(data[:take_size])
+ remaining_pad -= take_size
+
+ data_padded = DataProto.concat([data] + padding_protos)
+ else:
+ pad_size = 0
+ data_padded = data
+
+ return data_padded, pad_size
+
+
+def unpad_dataproto(data: "DataProto", pad_size: int) -> "DataProto":
+ if pad_size != 0:
+ data = data[:-pad_size]
+
+ return data
+
+
+def union_tensor_dict(tensor_dict1: TensorDict, tensor_dict2: TensorDict) -> TensorDict:
+ """Union two tensordicts."""
+ if tensor_dict1.batch_size != tensor_dict2.batch_size:
+ raise ValueError(
+ f"Two tensor dict must have identical batch size. Got {tensor_dict1.batch_size} and {tensor_dict2.batch_size}"
+ )
+
+ for key in tensor_dict2.keys():
+ if key in tensor_dict1 and not torch.equal(tensor_dict1[key], tensor_dict2[key]):
+ raise ValueError(f"Key already exists: {key}.")
+
+ tensor_dict1[key] = tensor_dict2[key]
+
+ return tensor_dict1
+
+
+def union_numpy_dict(tensor_dict1: dict[str, NDArray], tensor_dict2: dict[str, NDArray]) -> dict[str, NDArray]:
+ for key in tensor_dict2.keys():
+ if key in tensor_dict1:
+ assert isinstance(tensor_dict2[key], np.ndarray)
+ assert isinstance(tensor_dict1[key], np.ndarray)
+ if not np.all(tensor_dict1[key] == tensor_dict2[key]):
+ raise ValueError(f"Key already exists: {key}.")
+
+ tensor_dict1[key] = tensor_dict2[key]
+
+ return tensor_dict1
+
+
+def batch_collate(features: list[dict[str, Any]]) -> dict[str, list[Any]]:
+ if len(features) == 0:
+ return {}
+
+ all_keys: set[str] = set()
+ for feature in features:
+ all_keys.update(feature.keys())
+
+ batch_features: dict[str, list[Any]] = {key: [] for key in all_keys}
+ for feature in features:
+ for key in all_keys:
+ batch_features[key].append(feature.get(key, None))
+
+ return batch_features
+
+
+def fold_batch_dim(data: "DataProto", new_batch_size: int):
+ """
+ Fold a batch dim from [bsz, xxx] into [new_bsz, bsz // new_bsz, xxx]
+ """
+ batch_size = data.batch.batch_size[0]
+
+ assert batch_size % new_batch_size == 0
+
+ tensor: TensorDict = data.batch
+ non_tensor = data.non_tensor_batch
+
+ tensor = tensor.view(new_batch_size, -1)
+ tensor.auto_batch_size_(batch_dims=1)
+
+ for key, value in non_tensor.items():
+ non_tensor[key] = np.reshape(value, newshape=(new_batch_size, -1, *value.shape[1:]))
+
+ return DataProto(batch=tensor, non_tensor_batch=non_tensor, meta_info=data.meta_info)
+
+
+def collate_fn(data_items: list["DataProtoItem"]):
+ batch = []
+ non_tensor_batch = []
+ for data in data_items:
+ batch.append(data.batch)
+ non_tensor_batch.append(data.non_tensor_batch)
+
+ batch = torch.stack(batch).contiguous()
+ non_tensor_batch = batch_collate(non_tensor_batch)
+ non_tensor_batch = {key: np.array(value, dtype=object) for key, value in non_tensor_batch.items()}
+ return DataProto(batch=batch, non_tensor_batch=non_tensor_batch)
+
+
+@dataclass
+class DataProtoItem:
+ batch: Optional[TensorDict] = None
+ non_tensor_batch: dict[str, NDArray] = field(default_factory=dict)
+ meta_info: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class DataProto:
+ """
+ A DataProto is a data structure that aims to provide a standard protocol for data exchange between functions.
+ It contains a batch (TensorDict) and a meta_info (Dict). The batch is a TensorDict https://pytorch.org/tensordict/.
+ TensorDict allows you to manipulate a dictionary of Tensors like a single Tensor. Ideally, the tensors with the
+ same batch size should be put inside batch.
+ """
+
+ batch: Optional[TensorDict] = None
+ non_tensor_batch: dict[str, NDArray] = field(default_factory=dict)
+ meta_info: dict[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self):
+ self.check_consistency() # perform necessary checking
+
+ def __len__(self) -> int:
+ if self.batch is not None:
+ return self.batch.batch_size[0]
+ elif self.non_tensor_batch is not None and len(self.non_tensor_batch) > 0:
+ pivot_key = list(self.non_tensor_batch.keys())[0]
+ return self.non_tensor_batch[pivot_key].shape[0]
+ else:
+ return 0
+
+ def __getitem__(
+ self, item: Union[int, slice, list[int], np.ndarray, torch.Tensor]
+ ) -> Union["DataProto", "DataProtoItem"]:
+ if isinstance(item, slice):
+ return self.slice_select(item.start, item.stop, item.step)
+
+ if isinstance(item, (list, np.ndarray, torch.Tensor)):
+ return self.index_select(item)
+
+ if isinstance(item, (int, np.integer)):
+ tensor_data = self.batch[item] if self.batch is not None else None
+ non_tensor_data = {key: value[item] for key, value in self.non_tensor_batch.items()}
+ return DataProtoItem(batch=tensor_data, non_tensor_batch=non_tensor_data, meta_info=self.meta_info)
+
+ raise TypeError(f"Indexing with {type(item)} is not supported.")
+
+ def __getstate__(self) -> tuple[bytes, dict[str, NDArray], dict[str, Any]]:
+ if self.batch is not None:
+ batch_to_save: TensorDict = self.batch.contiguous()
+ batch_to_save: TensorDict = batch_to_save.consolidate()
+ else:
+ batch_to_save = None
+
+ buffer = io.BytesIO()
+ torch.save(batch_to_save, buffer)
+ buffer_bytes = buffer.getvalue()
+ return buffer_bytes, self.non_tensor_batch, self.meta_info
+
+ def __setstate__(self, data: tuple[bytes, dict[str, NDArray], dict[str, Any]]) -> None:
+ batch_deserialized_bytes, non_tensor_batch, meta_info = data
+ batch_deserialized = io.BytesIO(batch_deserialized_bytes)
+ batch = torch.load(batch_deserialized, weights_only=False, map_location="cpu")
+ self.batch = batch
+ self.non_tensor_batch = non_tensor_batch
+ self.meta_info = meta_info
+
+ def save_to_disk(self, filepath: str) -> None:
+ with open(filepath, "wb") as f:
+ pickle.dump(self, f)
+
+ @staticmethod
+ def load_from_disk(filepath: str) -> "DataProto":
+ with open(filepath, "rb") as f:
+ data = pickle.load(f)
+ return data
+
+ def print_size(self, prefix: str = "") -> None:
+ size_of_tensordict = 0
+ if self.batch is not None:
+ for tensor in self.batch.values():
+ if isinstance(tensor, torch.Tensor):
+ size_of_tensordict += tensor.element_size() * tensor.numel()
+
+ size_of_numpy_array = 0
+ for value in self.non_tensor_batch.values():
+ size_of_numpy_array += value.nbytes
+
+ size_of_numpy_array /= 1024**3
+ size_of_tensordict /= 1024**3
+
+ message = f"Size of tensordict: {size_of_tensordict} GB, size of non_tensor_batch: {size_of_numpy_array} GB."
+ print({prefix}, {message})
+
+ def check_consistency(self):
+ """Check the consistency of the DataProto. Mainly for batch and non_tensor_batch
+ We expose this function as a public one so that user can call themselves directly
+ """
+ if self.batch is not None:
+ assert len(self.batch.batch_size) == 1, "only support num_batch_dims=1"
+
+ if self.batch is not None and len(self.non_tensor_batch) != 0:
+ # TODO: we can actually lift this restriction if needed
+ assert len(self.batch.batch_size) == 1, "only support num_batch_dims=1 when non_tensor_batch is not empty."
+
+ batch_size = self.batch.batch_size[0]
+ for key, value in self.non_tensor_batch.items():
+ assert len(value) == batch_size, f"key {key} length {len(value)} is not equal to bsz {batch_size}."
+
+ @classmethod
+ def from_single_dict(
+ cls,
+ data: dict[str, Union[torch.Tensor, NDArray]],
+ meta_info: Optional[dict[str, Any]] = None,
+ ) -> "DataProto":
+ tensors, non_tensors = {}, {}
+ for key, value in data.items():
+ if isinstance(value, torch.Tensor):
+ tensors[key] = value
+ elif isinstance(value, np.ndarray):
+ non_tensors[key] = value
+ else:
+ raise ValueError(f"Unsupported type in data {type(value)}")
+
+ return DataProto.from_dict(tensors=tensors, non_tensors=non_tensors, meta_info=meta_info)
+
+ @classmethod
+ def from_dict(
+ cls,
+ tensors: Optional[dict[str, torch.Tensor]] = None,
+ non_tensors: Optional[dict[str, NDArray]] = None,
+ meta_info: Optional[dict[str, Any]] = None,
+ num_batch_dims: int = 1,
+ ) -> "DataProto":
+ """Create a DataProto from a dict of tensors. This assumes that
+ 1. All the tensor in tensors have the same dim0
+ 2. Only dim0 is the batch dim
+ """
+ assert num_batch_dims > 0, "num_batch_dims must be greater than zero"
+ if non_tensors is not None:
+ assert num_batch_dims == 1, "only support num_batch_dims=1 when non_tensors is not None."
+
+ tensors = tensors or {}
+ non_tensors = non_tensors or {}
+ meta_info = meta_info or {}
+ assert isinstance(tensors, dict) and isinstance(non_tensors, dict) and isinstance(meta_info, dict)
+
+ # get and check batch size
+ batch_size = None
+ pivot_key = None
+ for key, tensor in tensors.items():
+ if batch_size is None:
+ batch_size = tensor.shape[:num_batch_dims]
+ pivot_key = key
+ else:
+ current_batch = tensor.shape[:num_batch_dims]
+ assert batch_size == current_batch, (
+ f"Not all the tensor in tensors have the same batch size with batch_dims={num_batch_dims}. "
+ f"Got {pivot_key} has {batch_size}, {key} has {current_batch}."
+ )
+
+ for key, value in non_tensors.items():
+ if not isinstance(value, np.ndarray) or value.dtype != np.dtype(object):
+ non_tensors[key] = np.array(value, dtype=object)
+
+ tensor_dict = TensorDict(source=tensors, batch_size=batch_size) if tensors else None
+ return cls(batch=tensor_dict, non_tensor_batch=non_tensors, meta_info=meta_info)
+
+ def to(self, device: torch.device, non_blocking: bool = False) -> "DataProto":
+ """Move the batch to device
+
+ Args:
+ device (torch.device): the device to move to.
+ non_blocking (bool, optional): whether to use non-blocking mode. Defaults to False.
+
+ Returns:
+ DataProto: the current DataProto.
+
+ NOTE: remember to use torch.cuda.synchronize() after self.to("cpu") to avoid weird number
+ """
+ if self.batch is not None:
+ self.batch = self.batch.to(device, non_blocking=non_blocking)
+
+ return self
+
+ def select(
+ self,
+ batch_keys: Optional[list[str]] = None,
+ non_tensor_batch_keys: Optional[list[str]] = None,
+ meta_info_keys: Optional[list[str]] = None,
+ deepcopy: bool = False,
+ ) -> "DataProto":
+ """Select a subset of the DataProto via batch_keys and meta_info_keys
+
+ Args:
+ batch_keys (list, optional): a list of strings indicating the keys in batch to select
+ meta_info_keys (list, optional): a list of keys indicating the meta info to select
+
+ Returns:
+ DataProto: the DataProto with the selected batch_keys and meta_info_keys
+ """
+ # TODO (zhangchi.usc1992) whether to copy
+ if batch_keys is not None:
+ batch_keys = tuple(filter(lambda k: k in self.batch, batch_keys))
+ sub_batch = self.batch.select(*batch_keys)
+ else:
+ sub_batch = self.batch
+
+ if non_tensor_batch_keys is not None:
+ # we must convert it to tuple to avoid the missing elements
+ non_tensor_batch_keys = tuple(filter(lambda k: k in self.non_tensor_batch, non_tensor_batch_keys))
+ non_tensor_batch = {k: v for k, v in self.non_tensor_batch.items() if k in non_tensor_batch_keys}
+ else:
+ non_tensor_batch = self.non_tensor_batch
+
+ if deepcopy:
+ non_tensor_batch = copy.deepcopy(non_tensor_batch)
+
+ if meta_info_keys is not None:
+ meta_info_keys = tuple(filter(lambda k: k in self.meta_info, meta_info_keys))
+ sub_meta_info = {k: v for k, v in self.meta_info.items() if k in meta_info_keys}
+ else:
+ sub_meta_info = self.meta_info
+
+ if deepcopy:
+ sub_meta_info = copy.deepcopy(sub_meta_info)
+
+ return DataProto(batch=sub_batch, non_tensor_batch=non_tensor_batch, meta_info=sub_meta_info)
+
+ def index_select(self, index: Union[list[int], NDArray, torch.Tensor]) -> "DataProto":
+ """Select a subset of the DataProto via index.
+
+ Args:
+ index (list, ndarray, torch.Tensor): a list of indices to select.
+
+ Returns:
+ DataProto: the DataProto containing the selected indices.
+ """
+ if isinstance(index, list):
+ index = np.array(index, dtype=bool if isinstance(index[0], bool) else np.int32)
+ elif isinstance(index, torch.Tensor):
+ index = index.detach().cpu().numpy()
+
+ tensor_data = self.batch[index] if self.batch is not None else None
+ non_tensor_data = {key: value[index] for key, value in self.non_tensor_batch.items()}
+ return DataProto(batch=tensor_data, non_tensor_batch=non_tensor_data, meta_info=self.meta_info)
+
+ def slice_select(
+ self, start: Optional[int] = None, end: Optional[int] = None, step: Optional[int] = None
+ ) -> "DataProto":
+ """Select a subset of the DataProto via slice.
+
+ Args:
+ start (int, optional): the start index of the slice.
+ end (int, optional): the end index of the slice.
+ step (int, optional): the step of the slice.
+
+ Returns:
+ DataProto: the DataProto containing the selected slice.
+ """
+ index = slice(start, end, step)
+ tensor_data = self.batch[index] if self.batch is not None else None
+ non_tensor_data = {key: value[index] for key, value in self.non_tensor_batch.items()}
+ return DataProto(batch=tensor_data, non_tensor_batch=non_tensor_data, meta_info=self.meta_info)
+
+ def pop(
+ self,
+ batch_keys: Optional[list[str]] = None,
+ non_tensor_batch_keys: Optional[list[str]] = None,
+ meta_info_keys: Optional[list[str]] = None,
+ ) -> "DataProto":
+ """Pop a subset of the DataProto via `batch_keys` and `meta_info_keys`
+
+ Args:
+ batch_keys (list, optional): a list of strings indicating the keys in batch to pop
+ meta_info_keys (list, optional): a list of keys indicating the meta info to pop
+
+ Returns:
+ DataProto: the DataProto with the poped batch_keys and meta_info_keys
+ """
+ assert batch_keys is not None
+ non_tensor_batch_keys = non_tensor_batch_keys or []
+ meta_info_keys = meta_info_keys or []
+
+ tensors = {}
+ for key in filter(lambda k: k in self.batch, batch_keys):
+ tensors[key] = self.batch.pop(key)
+
+ non_tensors = {}
+ for key in filter(lambda k: k in self.non_tensor_batch, non_tensor_batch_keys):
+ non_tensors[key] = self.non_tensor_batch.pop(key)
+
+ meta_info = {}
+ for key in filter(lambda k: k in self.meta_info, meta_info_keys):
+ meta_info[key] = self.meta_info.pop(key)
+
+ return DataProto.from_dict(tensors=tensors, non_tensors=non_tensors, meta_info=meta_info)
+
+ def rename(
+ self, old_keys: Optional[Union[str, list[str]]] = None, new_keys: Optional[Union[str, list[str]]] = None
+ ) -> "DataProto":
+ """
+ Note that this function only rename the key in the batch
+ """
+
+ def validate_input(keys):
+ if keys is not None:
+ if isinstance(keys, str):
+ keys = [keys]
+ elif isinstance(keys, list):
+ pass
+ else:
+ raise TypeError(f"keys must be a list or a string, but got {type(keys)}")
+ return keys
+
+ old_keys = validate_input(old_keys)
+ new_keys = validate_input(new_keys)
+
+ if len(new_keys) != len(old_keys):
+ raise ValueError(
+ f"new_keys and old_keys must have the same length, but got {len(new_keys)} and {len(old_keys)}"
+ )
+
+ self.batch.rename_key_(tuple(old_keys), tuple(new_keys))
+
+ return self
+
+ def union(self, other: "DataProto") -> "DataProto":
+ """Union with another DataProto. Union batch and meta_info separately.
+ Throw an error if
+ - there are conflict keys in batch and they are not equal
+ - the batch size of two data batch is not the same
+ - there are conflict keys in meta_info and they are not the same.
+
+ Args:
+ other (DataProto): another DataProto to union
+
+ Returns:
+ DataProto: the DataProto after union
+ """
+ self.batch = union_tensor_dict(self.batch, other.batch)
+ self.non_tensor_batch = union_numpy_dict(self.non_tensor_batch, other.non_tensor_batch)
+ self.meta_info = union_two_dict(self.meta_info, other.meta_info)
+ return self
+
+ def make_iterator(
+ self, mini_batch_size: int, epochs: int, seed: int = None, dataloader_kwargs: dict[str, Any] = None
+ ):
+ """Make an iterator from the DataProto. This is built upon that TensorDict can be used as a normal Pytorch
+ dataset. See https://pytorch.org/tensordict/tutorials/data_fashion for more details.
+
+ Args:
+ mini_batch_size (int): mini-batch size when iterating the dataset. We require that
+ ``batch.batch_size[0] % mini_batch_size == 0``
+ epochs (int): number of epochs when iterating the dataset.
+ dataloader_kwargs: internally, it returns a DataLoader over the batch.
+ The dataloader_kwargs is the kwargs passed to the DataLoader
+
+ Returns:
+ Iterator: an iterator that yields a mini-batch data at a time. The total number of iteration steps is
+ ``self.batch.batch_size * epochs // mini_batch_size``
+ """
+ assert self.batch.batch_size[0] % mini_batch_size == 0, f"{self.batch.batch_size[0]} % {mini_batch_size} != 0"
+ if seed is not None:
+ generator = torch.Generator()
+ generator.manual_seed(seed)
+ else:
+ generator = None
+
+ dataloader_kwargs = dataloader_kwargs or {}
+ assert isinstance(dataloader_kwargs, dict)
+ train_dataloader = DataLoader(
+ dataset=self,
+ batch_size=mini_batch_size,
+ collate_fn=collate_fn,
+ generator=generator,
+ **dataloader_kwargs,
+ )
+
+ def get_data():
+ for _ in range(epochs):
+ for data in train_dataloader:
+ setattr(data, "meta_info", self.meta_info)
+ yield data
+
+ return iter(get_data())
+
+ def chunk(self, chunks: int) -> list["DataProto"]:
+ """Split the batch among dim=0 into chunks. The meta_info is passed to each DataProto after split.
+
+ Args:
+ chunks (int): the number of chunks to split on dim=0
+
+ Returns:
+ List[DataProto]: a list of DataProto after splitting
+ """
+ assert len(self) % chunks == 0, (
+ f"only support equal chunk. Got size of DataProto {len(self)} and chunk {chunks}."
+ )
+ if self.batch is not None:
+ batch_lst = self.batch.chunk(chunks=chunks, dim=0)
+ else:
+ batch_lst = [None for _ in range(chunks)]
+
+ non_tensor_batch_lst = [{} for _ in range(chunks)]
+ for key, value in self.non_tensor_batch.items():
+ non_tensor_lst = np.array_split(value, chunks)
+ for i in range(chunks):
+ non_tensor_batch_lst[i][key] = non_tensor_lst[i]
+
+ return [
+ DataProto(batch=batch_lst[i], non_tensor_batch=non_tensor_batch_lst[i], meta_info=self.meta_info)
+ for i in range(chunks)
+ ]
+
+ def split(self, split_size: int) -> list["DataProto"]:
+ """Split the batch among dim=0 into chunks. The meta_info is passed to each DataProto after split.
+
+ Args:
+ split_size (int): the size of each split
+
+ Returns:
+ List[DataProto]: a list of DataProto after splitting
+ """
+ assert len(self) % split_size == 0, (
+ f"only support equal split. Got size of DataProto {len(self)} and split {split_size}."
+ )
+ chunks = len(self) // split_size
+ return self.chunk(chunks)
+
+ @staticmethod
+ def concat(data: list["DataProto"]) -> "DataProto":
+ """Concat a list of DataProto. The batch is concatenated among dim=0.
+ The meta_info is assumed to be identical and will use the first one.
+
+ Args:
+ data (List[DataProto]): list of DataProto
+
+ Returns:
+ DataProto: concatenated DataProto
+ """
+ batch_lst = [batch.batch for batch in data]
+ new_batch = torch.cat(batch_lst, dim=0) if batch_lst[0] is not None else None
+ non_tensor_batch = batch_collate([d.non_tensor_batch for d in data])
+ for key, value in non_tensor_batch.items():
+ non_tensor_batch[key] = np.concatenate(value, axis=0)
+
+ return DataProto(batch=new_batch, non_tensor_batch=non_tensor_batch, meta_info=data[0].meta_info)
+
+ def reorder(self, indices: torch.Tensor) -> None:
+ """
+ Note that this operation is in-place
+ """
+ indices_np = indices.detach().numpy()
+ self.batch = self.batch[indices]
+ self.non_tensor_batch = {key: value[indices_np] for key, value in self.non_tensor_batch.items()}
+
+ def repeat(self, repeat_times: int, interleave: bool = True) -> "DataProto":
+ """
+ Repeat the batch data a specified number of times.
+
+ Args:
+ repeat_times (int): Number of times to repeat the data.
+ interleave (bool): Whether to interleave the repeated data.
+
+ Returns:
+ DataProto: A new DataProto with repeated data.
+ """
+ if self.batch is not None:
+ if interleave: # interleave the data
+ repeated_tensors = {
+ key: tensor.repeat_interleave(repeat_times, dim=0) for key, tensor in self.batch.items()
+ }
+ else: # stack the data
+ repeated_tensors = {
+ key: tensor.unsqueeze(0).expand(repeat_times, *tensor.shape).reshape(-1, *tensor.shape[1:])
+ for key, tensor in self.batch.items()
+ }
+
+ repeated_batch = TensorDict(
+ source=repeated_tensors,
+ batch_size=(self.batch.batch_size[0] * repeat_times,),
+ )
+ else:
+ repeated_batch = None
+
+ repeated_non_tensor_batch = {}
+ for key, value in self.non_tensor_batch.items():
+ if interleave:
+ repeated_non_tensor_batch[key] = np.repeat(value, repeat_times, axis=0)
+ else:
+ repeated_non_tensor_batch[key] = np.tile(value, (repeat_times,) + (1,) * (value.ndim - 1))
+
+ return DataProto(
+ batch=repeated_batch,
+ non_tensor_batch=repeated_non_tensor_batch,
+ meta_info=self.meta_info,
+ )
+
+
+@dataclass
+class DataProtoFuture:
+ """
+ DataProtoFuture aims to eliminate actual data fetching on driver. By doing so, the driver doesn't have to wait
+ for data so that asynchronous execution becomes possible.
+ DataProtoFuture contains a list of futures from another WorkerGroup of size world_size.
+ - collect_fn is a Callable that reduces the list of futures to a DataProto
+ - dispatch_fn is a Callable that partitions the DataProto into a list of DataProto of size world_size and then select
+
+ Potential issue: we can optimize dispatch_fn(collect_fn) such that only needed data is fetched on destination
+ - DataProtoFuture only supports directly passing from the output of a method to another input. You can't perform any
+ operation on the DataProtoFuture in driver.
+ """
+
+ collect_fn: Callable
+ futures: list[ray.ObjectRef]
+ dispatch_fn: Callable = None
+
+ @staticmethod
+ def concat(data: list[ray.ObjectRef]) -> "DataProtoFuture":
+ output = DataProtoFuture(collect_fn=DataProto.concat, futures=data)
+ return output
+
+ def chunk(self, chunks: int) -> list["DataProtoFuture"]:
+ from functools import partial
+
+ arg_future_lst = []
+ for i in range(chunks):
+ # note that we can't directly pass i and chunks
+ def dispatch_fn(x, i, chunks):
+ return x.chunk(chunks=chunks)[i]
+
+ arg_future = DataProtoFuture(
+ collect_fn=self.collect_fn, dispatch_fn=partial(dispatch_fn, i=i, chunks=chunks), futures=self.futures
+ )
+ arg_future_lst.append(arg_future)
+ return arg_future_lst
+
+ def get(self):
+ outputs = ray.get(self.futures) # dp_size
+ for output in outputs:
+ assert isinstance(output, DataProto)
+
+ outputs = self.collect_fn(outputs) # select dp, concat
+ if self.dispatch_fn is not None:
+ outputs = self.dispatch_fn(outputs) # split in batch dim, select using dp
+
+ return outputs
+
+
+def allgather_dict_tensors(
+ tensors: Union[dict[str, torch.Tensor], TensorDict], size: int, group: ProcessGroup, dim: int = 0
+) -> Union[dict[str, torch.Tensor], TensorDict]:
+ """
+ TODO: optimize this.
+ - We can use async ops
+ - We can use only one allgather
+ """
+ if isinstance(tensors, TensorDict):
+ is_tensor_dict = True
+ tensors_as_dict = tensors.to_dict()
+ else:
+ tensors_as_dict = tensors
+ is_tensor_dict = False
+
+ output = {}
+ sorted_keys = sorted(tensors_as_dict.keys())
+ for key in sorted_keys:
+ value = tensors_as_dict[key]
+ output[key] = [torch.empty_like(value) for _ in range(size)]
+ torch.distributed.all_gather(output[key], value, group=group, async_op=False)
+ output[key] = torch.cat(output[key], dim=dim)
+
+ if is_tensor_dict:
+ output = TensorDict(source=output, batch_size=tensors.batch_size[0] * size)
+
+ return output
+
+
+def all_gather_data_proto(data: DataProto, size: int, group: ProcessGroup) -> None:
+ # Note that this is an inplace operator just like torch.distributed.all_gather
+ prev_device = data.batch.device
+ data.batch = data.batch.cuda(device=torch.cuda.current_device())
+ data.batch = allgather_dict_tensors(data.batch.contiguous(), size=size, group=group, dim=0)
+ data.batch = data.batch.to(prev_device)
+ # all gather non_tensor_batch
+ all_non_tensor_batch = [None for _ in range(size)]
+ torch.distributed.all_gather_object(all_non_tensor_batch, data.non_tensor_batch, group=group)
+ data.non_tensor_batch = {k: np.concatenate([d[k] for d in all_non_tensor_batch]) for k in data.non_tensor_batch}
diff --git a/code/verl/single_controller/__init__.py b/code/verl/single_controller/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ce90c5eb352d85c59105c0dc85b5f1dd576f095
--- /dev/null
+++ b/code/verl/single_controller/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2024 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.
diff --git a/code/verl/single_controller/base/__init__.py b/code/verl/single_controller/base/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..46c9670eebda2595b2ea248c640f9d533506c226
--- /dev/null
+++ b/code/verl/single_controller/base/__init__.py
@@ -0,0 +1,19 @@
+# Copyright 2024 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 .worker import Worker
+from .worker_group import ClassWithInitArgs, ResourcePool, WorkerGroup
+
+
+__all__ = ["ClassWithInitArgs", "ResourcePool", "Worker", "WorkerGroup"]
diff --git a/code/verl/single_controller/base/decorator.py b/code/verl/single_controller/base/decorator.py
new file mode 100644
index 0000000000000000000000000000000000000000..3967b1eee775557ec59c34031c1593b7f8252132
--- /dev/null
+++ b/code/verl/single_controller/base/decorator.py
@@ -0,0 +1,213 @@
+# Copyright 2024 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 enum import Enum, auto
+from functools import wraps
+from types import FunctionType
+from typing import TYPE_CHECKING, Literal, Union
+
+import ray
+
+from ...protocol import DataProto, DataProtoFuture
+
+
+if TYPE_CHECKING:
+ from .worker_group import WorkerGroup
+
+
+# here we add a magic number of avoid user-defined function already have this attribute
+MAGIC_ATTR = "attrs_3141562937"
+
+
+class Dispatch(Enum):
+ RANK_ZERO = auto()
+ ONE_TO_ALL = auto()
+ ALL_TO_ALL = auto()
+ DP_COMPUTE = auto()
+ DP_COMPUTE_PROTO = auto()
+ DP_COMPUTE_PROTO_WITH_FUNC = auto()
+ DP_COMPUTE_METRIC = auto()
+
+
+class Execute(Enum):
+ ALL = 0
+ RANK_ZERO = 1
+
+
+def _split_args_kwargs_data_proto(chunks: int, *args, **kwargs):
+ splitted_args = []
+ for arg in args:
+ assert isinstance(arg, (DataProto, DataProtoFuture))
+ splitted_args.append(arg.chunk(chunks=chunks))
+
+ splitted_kwargs = {}
+ for key, value in kwargs.items():
+ assert isinstance(value, (DataProto, DataProtoFuture))
+ splitted_kwargs[key] = value.chunk(chunks=chunks)
+
+ return splitted_args, splitted_kwargs
+
+
+def dispatch_one_to_all(worker_group: "WorkerGroup", *args, **kwargs):
+ args = tuple([arg] * worker_group.world_size for arg in args)
+ kwargs = {k: [v] * worker_group.world_size for k, v in kwargs.items()}
+ return args, kwargs
+
+
+def dispatch_all_to_all(worker_group: "WorkerGroup", *args, **kwargs):
+ return args, kwargs
+
+
+def collect_all_to_all(worker_group: "WorkerGroup", output):
+ return output
+
+
+def _concat_data_proto_or_future(outputs: list[DataProto]) -> DataProto:
+ # make sure all the elements in output has the same type
+ for output in outputs:
+ assert type(output) is type(outputs[0])
+
+ output = outputs[0]
+
+ if isinstance(output, DataProto):
+ return DataProto.concat(outputs)
+ elif isinstance(output, ray.ObjectRef):
+ return DataProtoFuture.concat(outputs)
+ else:
+ raise NotImplementedError
+
+
+def dispatch_dp_compute(worker_group: "WorkerGroup", *args, **kwargs):
+ for arg in args:
+ assert isinstance(arg, (tuple, list)) and len(arg) == worker_group.world_size
+
+ for value in kwargs.values():
+ assert isinstance(value, (tuple, list)) and len(value) == worker_group.world_size
+
+ return args, kwargs
+
+
+def collect_dp_compute(worker_group: "WorkerGroup", outputs: list[DataProto]) -> list[DataProto]:
+ assert len(outputs) == worker_group.world_size
+ return outputs
+
+
+def dispatch_dp_compute_data_proto(worker_group: "WorkerGroup", *args, **kwargs):
+ splitted_args, splitted_kwargs = _split_args_kwargs_data_proto(worker_group.world_size, *args, **kwargs)
+ return splitted_args, splitted_kwargs
+
+
+def dispatch_dp_compute_data_proto_with_func(worker_group: "WorkerGroup", *args, **kwargs):
+ assert type(args[0]) is FunctionType # NOTE: The first one args is a function!
+ splitted_args, splitted_kwargs = _split_args_kwargs_data_proto(worker_group.world_size, *args[1:], **kwargs)
+ splitted_args_with_func = [[args[0]] * worker_group.world_size] + splitted_args
+ return splitted_args_with_func, splitted_kwargs
+
+
+def collect_dp_compute_data_proto(worker_group: "WorkerGroup", outputs: list[DataProto]) -> DataProto:
+ for output in outputs:
+ assert isinstance(output, (DataProto, ray.ObjectRef)), f"Expect a DataProto, but got {type(output)}"
+
+ outputs = collect_dp_compute(worker_group, outputs)
+ return _concat_data_proto_or_future(outputs)
+
+
+def get_predefined_dispatch_fn(dispatch_mode: Dispatch):
+ predefined_dispatch_mode_fn = {
+ Dispatch.ONE_TO_ALL: {
+ "dispatch_fn": dispatch_one_to_all,
+ "collect_fn": collect_all_to_all,
+ },
+ Dispatch.ALL_TO_ALL: {
+ "dispatch_fn": dispatch_all_to_all,
+ "collect_fn": collect_all_to_all,
+ },
+ Dispatch.DP_COMPUTE: {
+ "dispatch_fn": dispatch_dp_compute,
+ "collect_fn": collect_dp_compute,
+ },
+ Dispatch.DP_COMPUTE_PROTO: {
+ "dispatch_fn": dispatch_dp_compute_data_proto,
+ "collect_fn": collect_dp_compute_data_proto,
+ },
+ Dispatch.DP_COMPUTE_PROTO_WITH_FUNC: {
+ "dispatch_fn": dispatch_dp_compute_data_proto_with_func,
+ "collect_fn": collect_dp_compute_data_proto,
+ },
+ Dispatch.DP_COMPUTE_METRIC: {
+ "dispatch_fn": dispatch_dp_compute_data_proto,
+ "collect_fn": collect_dp_compute,
+ },
+ }
+ return predefined_dispatch_mode_fn[dispatch_mode]
+
+
+def get_predefined_execute_fn(execute_mode: Execute):
+ """
+ Note that here we only asks execute_all and execute_rank_zero to be implemented
+ Leave the choice of how these two functions handle argument 'blocking' to users
+ """
+ predefined_execute_mode_fn = {
+ Execute.ALL: {"execute_fn_name": "execute_all"},
+ Execute.RANK_ZERO: {"execute_fn_name": "execute_rank_zero"},
+ }
+ return predefined_execute_mode_fn[execute_mode]
+
+
+def _check_dispatch_mode(dispatch_mode: Union[Dispatch, dict[Literal["dispatch_fn", "collect_fn"], FunctionType]]):
+ assert isinstance(dispatch_mode, (Dispatch, dict)), (
+ f"dispatch_mode must be a Dispatch or a Dict. Got {dispatch_mode}"
+ )
+ if isinstance(dispatch_mode, dict):
+ necessary_keys = ["dispatch_fn", "collect_fn"]
+ for key in necessary_keys:
+ assert key in dispatch_mode, f"key {key} should be in dispatch_mode if it is a dictionary"
+
+
+def _check_execute_mode(execute_mode: Execute):
+ assert isinstance(execute_mode, Execute), f"execute_mode must be a Execute. Got {execute_mode}"
+
+
+def _materialize_futures(*args, **kwargs):
+ new_args = []
+ for arg in args:
+ if isinstance(arg, DataProtoFuture):
+ arg = arg.get()
+ # add more type to materialize
+ new_args.append(arg)
+
+ for key, value in kwargs.items():
+ if isinstance(value, DataProtoFuture):
+ kwargs[key] = value.get()
+
+ new_args = tuple(new_args)
+ return new_args, kwargs
+
+
+def register(dispatch_mode=Dispatch.ALL_TO_ALL, execute_mode=Execute.ALL, blocking=True, materialize_futures=True):
+ _check_dispatch_mode(dispatch_mode=dispatch_mode)
+ _check_execute_mode(execute_mode=execute_mode)
+
+ def decorator(func):
+ @wraps(func)
+ def inner(*args, **kwargs):
+ if materialize_futures:
+ args, kwargs = _materialize_futures(*args, **kwargs)
+ return func(*args, **kwargs)
+
+ attrs = {"dispatch_mode": dispatch_mode, "execute_mode": execute_mode, "blocking": blocking}
+ setattr(inner, MAGIC_ATTR, attrs)
+ return inner
+
+ return decorator
diff --git a/code/verl/single_controller/base/register_center/__init__.py b/code/verl/single_controller/base/register_center/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ce90c5eb352d85c59105c0dc85b5f1dd576f095
--- /dev/null
+++ b/code/verl/single_controller/base/register_center/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2024 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.
diff --git a/code/verl/single_controller/base/register_center/ray.py b/code/verl/single_controller/base/register_center/ray.py
new file mode 100644
index 0000000000000000000000000000000000000000..de7f702a894ef5cd87fa90931e6bd260f8b68b13
--- /dev/null
+++ b/code/verl/single_controller/base/register_center/ray.py
@@ -0,0 +1,28 @@
+# Copyright 2024 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.
+
+import ray
+
+
+@ray.remote
+class WorkerGroupRegisterCenter:
+ def __init__(self, rank_zero_info):
+ self.rank_zero_info = rank_zero_info
+
+ def get_rank_zero_info(self):
+ return self.rank_zero_info
+
+
+def create_worker_group_register_center(name, info):
+ return WorkerGroupRegisterCenter.options(name=name).remote(info)
diff --git a/code/verl/single_controller/base/worker.py b/code/verl/single_controller/base/worker.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ecffca6b8d7589b292fbb25b5cf74c004cc4fad
--- /dev/null
+++ b/code/verl/single_controller/base/worker.py
@@ -0,0 +1,202 @@
+# Copyright 2024 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.
+"""
+the class for Worker
+"""
+
+import os
+import socket
+from dataclasses import dataclass
+from typing import Tuple
+
+import ray
+import torch
+
+from .decorator import Dispatch, Execute, register
+from .register_center.ray import create_worker_group_register_center
+
+
+@dataclass
+class DistRankInfo:
+ tp_rank: int
+ dp_rank: int
+ pp_rank: int
+
+
+@dataclass
+class DistGlobalInfo:
+ tp_size: int
+ dp_size: int
+ pp_size: int
+
+
+class WorkerHelper:
+ def _get_node_ip(self) -> str:
+ host_ipv4 = os.getenv("MY_HOST_IP", None)
+ host_ipv6 = os.getenv("MY_HOST_IPV6", None)
+ host_ip_by_env = host_ipv4 or host_ipv6
+ host_ip_by_sdk = ray._private.services.get_node_ip_address()
+
+ host_ip = host_ip_by_env or host_ip_by_sdk
+ return host_ip
+
+ def _get_free_port(self) -> int:
+ with socket.socket() as sock:
+ sock.bind(("", 0))
+ return sock.getsockname()[1]
+
+ def get_availale_master_addr_port(self) -> Tuple[str, str]:
+ return self._get_node_ip(), str(self._get_free_port())
+
+ def _get_pid(self):
+ return
+
+
+class WorkerMeta:
+ keys = [
+ "WORLD_SIZE",
+ "RANK",
+ "LOCAL_WORLD_SIZE",
+ "LOCAL_RANK",
+ "MASTER_ADDR",
+ "MASTER_PORT",
+ "CUDA_VISIBLE_DEVICES",
+ ]
+
+ def __init__(self, store) -> None:
+ self._store = store
+
+ def to_dict(self):
+ return {f"_{key.lower()}": self._store.get(f"_{key.lower()}", None) for key in WorkerMeta.keys}
+
+
+# we assume that in each WorkerGroup, there is a Master Worker
+class Worker(WorkerHelper):
+ """A (distributed) worker."""
+
+ _world_size: int
+ _rank: int
+ _local_world_size: int
+ _local_rank: int
+ _master_addr: str
+ _master_port: str
+ _cuda_visible_devices: str
+
+ def __new__(cls, *args, **kwargs):
+ instance = super().__new__(cls)
+
+ # note that here we use int to distinguish
+ disable_worker_init = int(os.getenv("DISABLE_WORKER_INIT", 0))
+ if disable_worker_init:
+ return instance
+
+ rank = os.getenv("RANK", None)
+ worker_group_prefix = os.getenv("WG_PREFIX", None)
+
+ # when decorator @ray.remote applies, __new__ will be called while we don't want to apply _configure_before_init
+ if None not in [rank, worker_group_prefix] and "ActorClass(" not in cls.__name__:
+ instance._configure_before_init(f"{worker_group_prefix}_register_center", int(rank))
+
+ return instance
+
+ def _configure_before_init(self, register_center_name: str, rank: int):
+ assert isinstance(rank, int), f"rank must be int, instead of {type(rank)}"
+
+ if rank == 0:
+ master_addr, master_port = self.get_availale_master_addr_port()
+ rank_zero_info = {
+ "MASTER_ADDR": master_addr,
+ "MASTER_PORT": master_port,
+ }
+ self.register_center = create_worker_group_register_center(name=register_center_name, info=rank_zero_info)
+ os.environ.update(rank_zero_info)
+
+ def __init__(self, cuda_visible_devices=None) -> None:
+ # construct a meta from envrionment variable. Note that the import must be inside the class because it is executed remotely
+ world_size = int(os.getenv("WORLD_SIZE"))
+ rank = int(os.getenv("RANK"))
+ self._rank = rank
+ self._world_size = world_size
+
+ if "AMD" in torch.cuda.get_device_name():
+ os.environ["CUDA_VISIBLE_DEVICES"] = os.getenv("ROCR_VISIBLE_DEVICES")
+ os.environ["LOCAL_RANK"] = os.getenv("RAY_LOCAL_RANK")
+ cuda_visible_devices = os.getenv("LOCAL_RANK", "0")
+ torch.cuda.set_device(int(cuda_visible_devices))
+
+ master_addr = os.getenv("MASTER_ADDR")
+ master_port = os.getenv("MASTER_PORT")
+
+ local_world_size = int(os.getenv("LOCAL_WORLD_SIZE", "1"))
+ local_rank = int(os.getenv("LOCAL_RANK", "0"))
+
+ store = {
+ "_world_size": world_size,
+ "_rank": rank,
+ "_local_world_size": local_world_size,
+ "_local_rank": local_rank,
+ "_master_addr": master_addr,
+ "_master_port": master_port,
+ }
+ if cuda_visible_devices is not None:
+ store["_cuda_visible_devices"] = cuda_visible_devices
+
+ meta = WorkerMeta(store=store)
+ self._configure_with_meta(meta=meta)
+
+ def _configure_with_meta(self, meta: WorkerMeta):
+ """
+ This function should only be called inside by WorkerGroup
+ """
+ assert isinstance(meta, WorkerMeta)
+ self.__dict__.update(meta.to_dict()) # this is hacky
+ # print(f"__dict__: {self.__dict__}")
+ for key in WorkerMeta.keys:
+ val = self.__dict__.get(f"_{key.lower()}", None)
+ if val is not None:
+ # print(f"set {key} to {val}")
+ os.environ[key] = str(val)
+
+ os.environ["REDIS_STORE_SERVER_HOST"] = (
+ str(self._master_addr).replace("[", "").replace("]", "") if self._master_addr else ""
+ )
+
+ def get_master_addr_port(self):
+ return self._master_addr, self._master_port
+
+ def get_cuda_visible_devices(self):
+ cuda_visible_devices = os.getenv("CUDA_VISIBLE_DEVICES", "not set")
+ return cuda_visible_devices
+
+ def print_rank0(self, *args, **kwargs):
+ if self.rank == 0:
+ print(*args, **kwargs)
+
+ @property
+ def world_size(self):
+ return self._world_size
+
+ @property
+ def rank(self):
+ return self._rank
+
+ @register(dispatch_mode=Dispatch.DP_COMPUTE_PROTO_WITH_FUNC)
+ def execute_with_func_generator(self, func, *args, **kwargs):
+ ret_proto = func(self, *args, **kwargs)
+ return ret_proto
+
+ @register(dispatch_mode=Dispatch.ALL_TO_ALL, execute_mode=Execute.RANK_ZERO)
+ def execute_func_rank_zero(self, func, *args, **kwargs):
+ result = func(*args, **kwargs)
+ return result
diff --git a/code/verl/single_controller/base/worker_group.py b/code/verl/single_controller/base/worker_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..092aca841ce8b1ddd8b00f58502c597859eda8ca
--- /dev/null
+++ b/code/verl/single_controller/base/worker_group.py
@@ -0,0 +1,194 @@
+# Copyright 2024 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.
+"""
+the class of WorkerGroup
+"""
+
+import logging
+import signal
+import threading
+import time
+from typing import Any, Callable, Optional
+
+from .decorator import MAGIC_ATTR, Dispatch, get_predefined_dispatch_fn, get_predefined_execute_fn
+
+
+class ResourcePool:
+ """The resource pool with meta info such as world size."""
+
+ def __init__(
+ self, process_on_nodes: Optional[Any] = None, max_colocate_count: int = 10, n_gpus_per_node: int = 8
+ ) -> None:
+ if process_on_nodes is None:
+ process_on_nodes = []
+
+ self._store = process_on_nodes
+ self.max_colocate_count = max_colocate_count
+ self.n_gpus_per_node = n_gpus_per_node # this is left for future huawei GPU that contains 16 GPUs per node
+
+ def add_node(self, process_count):
+ self._store.append(process_count)
+
+ @property
+ def world_size(self):
+ return sum(self._store)
+
+ def __call__(self) -> Any:
+ return self._store
+
+ @property
+ def store(self):
+ return self._store
+
+ def local_world_size_list(self) -> list[int]:
+ nested_local_world_size_list = [
+ [local_world_size for _ in range(local_world_size)] for local_world_size in self._store
+ ]
+ return [item for row in nested_local_world_size_list for item in row]
+
+ def local_rank_list(self) -> list[int]:
+ nested_local_rank_list = [[i for i in range(local_world_size)] for local_world_size in self._store] # noqa: C416
+ return [item for row in nested_local_rank_list for item in row]
+
+
+class ClassWithInitArgs:
+ """
+ This class stores a class constructor and the args/kwargs to construct the class.
+ It is used to instantiate the remote class.
+ """
+
+ def __init__(self, cls, *args, **kwargs) -> None:
+ self.cls = cls
+ self.args = args
+ self.kwargs = kwargs
+
+ def __call__(self) -> Any:
+ return self.cls(*self.args, **self.kwargs)
+
+
+def check_workers_alive(workers: list, is_alive: Callable, gap_time: float = 1) -> None:
+ while True:
+ for worker in workers:
+ if not is_alive(worker):
+ logging.warning(f"Worker {worker} is not alive, sending signal to main thread")
+ signal.raise_signal(signal.SIGABRT)
+
+ time.sleep(gap_time)
+
+
+class WorkerGroup:
+ """A group of workers"""
+
+ def __init__(self, resource_pool: ResourcePool, **kwargs) -> None:
+ self._is_init_with_detached_workers = True if resource_pool is None else False
+
+ if resource_pool is not None:
+ # handle the case when WorkGroup is attached to an existing one
+ self._procecss_dispatch_config = resource_pool()
+ else:
+ self._procecss_dispatch_config = None
+
+ self._workers = []
+ self._worker_names = []
+
+ self._master_addr = None
+ self._master_port = None
+
+ self._checker_thread: threading.Thread = None
+
+ def _is_worker_alive(self, worker):
+ raise NotImplementedError("WorkerGroup._is_worker_alive called, should be implemented in derived class.")
+
+ def _block_until_all_workers_alive(self) -> None:
+ while True:
+ all_state = [self._is_worker_alive(worker) for worker in self._workers]
+ if False in all_state:
+ time.sleep(1)
+ else:
+ break
+
+ def start_worker_aliveness_check(self, every_n_seconds=1) -> None:
+ # before starting checking worker aliveness, make sure all workers are already alive
+ self._block_until_all_workers_alive()
+
+ self._checker_thread = threading.Thread(
+ target=check_workers_alive, args=(self._workers, self._is_worker_alive, every_n_seconds)
+ )
+ self._checker_thread.start()
+
+ @property
+ def world_size(self):
+ return len(self._workers)
+
+ def _bind_worker_method(self, user_defined_cls, func_generator):
+ """
+ Bind the worker method to the WorkerGroup
+ """
+ for method_name in dir(user_defined_cls):
+ try:
+ method = getattr(user_defined_cls, method_name)
+ assert callable(method), f"{method_name} in {user_defined_cls} is not callable"
+ except Exception:
+ # if it is a property, it will fail because Class doesn't have instance property
+ continue
+
+ if hasattr(method, MAGIC_ATTR):
+ # this method is decorated by register
+ attribute = getattr(method, MAGIC_ATTR)
+ assert isinstance(attribute, dict), f"attribute must be a dictionary. Got {type(attribute)}"
+ assert "dispatch_mode" in attribute, "attribute must contain dispatch_mode in its key"
+
+ dispatch_mode = attribute["dispatch_mode"]
+ execute_mode = attribute["execute_mode"]
+ blocking = attribute["blocking"]
+
+ # get dispatch fn
+ if isinstance(dispatch_mode, Dispatch):
+ # get default dispatch fn
+ fn = get_predefined_dispatch_fn(dispatch_mode=dispatch_mode)
+ dispatch_fn = fn["dispatch_fn"]
+ collect_fn = fn["collect_fn"]
+ else:
+ assert isinstance(dispatch_mode, dict)
+ assert "dispatch_fn" in dispatch_mode
+ assert "collect_fn" in dispatch_mode
+ dispatch_fn = dispatch_mode["dispatch_fn"]
+ collect_fn = dispatch_mode["collect_fn"]
+
+ # get execute_fn_name
+ execute_mode = get_predefined_execute_fn(execute_mode=execute_mode)
+ wg_execute_fn_name = execute_mode["execute_fn_name"]
+
+ # get execute_fn from string
+ try:
+ execute_fn = getattr(self, wg_execute_fn_name)
+ assert callable(execute_fn), "execute_fn must be callable"
+ except Exception:
+ print(f"execute_fn {wg_execute_fn_name} is invalid")
+ raise
+
+ # bind a new method to the RayWorkerGroup
+ func = func_generator(
+ self,
+ method_name,
+ dispatch_fn=dispatch_fn,
+ collect_fn=collect_fn,
+ execute_fn=execute_fn,
+ blocking=blocking,
+ )
+
+ try:
+ setattr(self, method_name, func)
+ except Exception:
+ raise ValueError(f"Fail to set method_name {method_name}")
diff --git a/code/verl/single_controller/ray/__init__.py b/code/verl/single_controller/ray/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..25b3141cee9f146d18c92b5625a327a1d9f2dbce
--- /dev/null
+++ b/code/verl/single_controller/ray/__init__.py
@@ -0,0 +1,18 @@
+# Copyright 2024 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 .base import RayClassWithInitArgs, RayResourcePool, RayWorkerGroup, create_colocated_worker_cls
+
+
+__all__ = ["RayClassWithInitArgs", "RayResourcePool", "RayWorkerGroup", "create_colocated_worker_cls"]
diff --git a/code/verl/single_controller/ray/base.py b/code/verl/single_controller/ray/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..048ee219ebe47f09433bf30a71183d7a0a3d8c39
--- /dev/null
+++ b/code/verl/single_controller/ray/base.py
@@ -0,0 +1,493 @@
+# Copyright 2024 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.
+
+import os
+import random
+import re
+import string
+import time
+from typing import Any, Optional
+from unittest.mock import patch
+
+import ray
+from ray.actor import ActorHandle
+from ray.experimental.state.api import get_actor
+from ray.util import list_named_actors
+from ray.util.placement_group import PlacementGroup, placement_group
+from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy, PlacementGroupSchedulingStrategy
+
+from ..base import ClassWithInitArgs, ResourcePool, Worker, WorkerGroup
+from ..base.decorator import MAGIC_ATTR
+
+
+__all__ = ["Worker"]
+
+
+def get_random_string(length: int) -> str:
+ letters_digits = string.ascii_letters + string.digits
+ return "".join(random.choice(letters_digits) for _ in range(length))
+
+
+def func_generator(self, method_name, dispatch_fn, collect_fn, execute_fn, blocking):
+ def func(*args, **kwargs):
+ args, kwargs = dispatch_fn(self, *args, **kwargs)
+ output = execute_fn(method_name, *args, **kwargs)
+ if blocking:
+ output = ray.get(output)
+ output = collect_fn(self, output)
+ return output
+
+ return func
+
+
+def sort_placement_group_by_node_ip(pgs: list[PlacementGroup]) -> list[PlacementGroup]:
+ """
+ Sort the placement groups by node ip, all bundles in a single placement group should be on the same node.
+
+ FSDPCheckpointManager saves sharded model states and optimizer states in local storage, which requires RANK
+ to be consistent across nodes when resume from checkpoint.
+
+ With this function, if there's only one resource pool and there's no node change, RANK should be consistent
+ across nodes in multiple ray jobs, even if the whole ray cluster is restarted.
+ """
+ node_ip = {node["NodeID"]: node["NodeManagerAddress"] for node in ray.nodes()}
+ pg_ip = {}
+ for pg in pgs:
+ specs = ray._private.state.state.placement_group_table(pg.id)
+ # all bunles should be on the same node
+ node_id = specs["bundles_to_node_id"][0]
+ pg_ip[pg.id] = node_ip[node_id]
+
+ return sorted(pgs, key=lambda pg: pg_ip[pg.id])
+
+
+class RayResourcePool(ResourcePool):
+ def __init__(
+ self,
+ process_on_nodes: list[int] = None,
+ use_gpu: bool = True,
+ name_prefix: str = "",
+ max_colocate_count: int = 5,
+ detached: bool = False,
+ ) -> None:
+ super().__init__(process_on_nodes, max_colocate_count)
+ self.use_gpu = use_gpu
+ # print(f"in RayProcessDispatchConfiguration: name_prefix = {name_prefix}")
+ self.name_prefix = name_prefix
+ self.pgs = None
+ self.detached = detached
+
+ def get_placement_groups(self, strategy: str = "STRICT_PACK", name: Optional[str] = None) -> list[PlacementGroup]:
+ if self.pgs is not None:
+ return self.pgs
+
+ pg_name_prefix = (
+ name if name else f"{self.name_prefix}verl_group_{'_'.join([str(count) for count in self._store])}:"
+ )
+ # print(f"pg_name_prefix = {pg_name_prefix}")
+ pg_scheme = [
+ [
+ {"CPU": self.max_colocate_count, "GPU": 1} if self.use_gpu else {"CPU": self.max_colocate_count}
+ for _ in range(process_count)
+ ]
+ for process_count in self._store
+ ]
+
+ lifetime = "detached" if self.detached else None
+
+ pgs = [
+ placement_group(bundles=bundles, strategy=strategy, name=pg_name_prefix + str(idx), lifetime=lifetime)
+ for idx, bundles in enumerate(pg_scheme)
+ ]
+
+ ray.get([pg.ready() for pg in pgs])
+
+ self.pgs = pgs
+ return pgs
+
+
+def extract_pg_from_exist(
+ resource_pools: dict[str, RayResourcePool], src_role_names: list[str], resource_pool: RayResourcePool
+) -> list[PlacementGroup]:
+ src_pgs = [
+ pg
+ for role_name, resource_pool in resource_pools.items()
+ for pg in resource_pool.get_placement_groups()
+ if role_name in src_role_names
+ ]
+
+ sorted_src_pgs = sorted(src_pgs, key=lambda pg: pg.bundle_count, reverse=True)
+ sorted_process_on_nodes = sorted([(val, idx) for idx, val in enumerate(resource_pool.store)], reverse=True)
+
+ unsorted_pgs: list[tuple[int, PlacementGroup]] = []
+ searching_idx = 0
+ for request_process, original_idx in sorted_process_on_nodes:
+ assert searching_idx < len(sorted_src_pgs), f"no enough nodes for request: searching {searching_idx} th node"
+ assert request_process <= sorted_src_pgs[searching_idx].bundle_count, (
+ f"requesting {request_process} processes, bundle count cannot satisfy"
+ )
+ unsorted_pgs.append((original_idx, sorted_src_pgs[searching_idx]))
+ searching_idx += 1
+
+ return [pg for _, pg in sorted(unsorted_pgs)]
+
+
+def merge_resource_pool(rp1: RayResourcePool, rp2: RayResourcePool) -> RayResourcePool:
+ assert rp1.use_gpu == rp2.use_gpu, "Both RayResourcePool must either use_gpu or not"
+ assert rp1.max_colocate_count == rp2.max_colocate_count, (
+ "Both RayResourcePool must has the same max_colocate_count"
+ )
+ assert rp1.n_gpus_per_node == rp2.n_gpus_per_node, "Both RayResourcePool must has the same n_gpus_per_node"
+ assert rp1.detached == rp2.detached, "Detached ResourcePool cannot be merged with non-detached ResourcePool"
+
+ new_store = rp1.store + rp2.store
+
+ merged = RayResourcePool(new_store, rp1.use_gpu, f"{rp1.name_prefix}_{rp2.name_prefix}")
+ merged.pgs = rp1.get_placement_groups() + rp2.get_placement_groups()
+
+ return merged
+
+
+class RayClassWithInitArgs(ClassWithInitArgs):
+ def __init__(self, cls, *args, **kwargs) -> None:
+ # self._options = kwargs.pop('options', dict())
+ super().__init__(cls, *args, **kwargs)
+ self._options = {}
+ self._additional_resource = {}
+
+ def set_additional_resource(self, additional_resource):
+ self._additional_resource = additional_resource
+
+ def update_options(self, options: dict):
+ self._options.update(options)
+
+ def __call__(
+ self,
+ placement_group: PlacementGroup,
+ placement_group_bundle_idx: int,
+ use_gpu: bool = True,
+ num_gpus: int = 1,
+ sharing_with: Worker = None,
+ ) -> Any:
+ if sharing_with is not None:
+ target_node_id = ray.get(sharing_with.get_node_id.remote())
+ cuda_visible_devices = ray.get(sharing_with.get_cuda_visible_devices.remote())
+ options = {"scheduling_strategy": NodeAffinitySchedulingStrategy(node_id=target_node_id, soft=False)}
+ return self.cls.options(**options).remote(
+ *self.args, cuda_visible_devices=cuda_visible_devices, **self.kwargs
+ )
+
+ options = {
+ "scheduling_strategy": PlacementGroupSchedulingStrategy(
+ placement_group=placement_group, placement_group_bundle_index=placement_group_bundle_idx
+ )
+ }
+ options.update(self._options)
+
+ if use_gpu:
+ options["num_gpus"] = num_gpus
+
+ if len(self._additional_resource) > 1:
+ for k, v in self._additional_resource.items():
+ options[k] = v
+
+ # print("cls:", self.cls)
+ # print("args: ", self.args)
+ # print("kwargs: ", self.kwargs)
+ return self.cls.options(**options).remote(*self.args, **self.kwargs)
+
+
+class RayWorkerGroup(WorkerGroup):
+ def __init__(
+ self,
+ resource_pool: RayResourcePool = None,
+ ray_cls_with_init: RayClassWithInitArgs = None,
+ bin_pack: bool = True,
+ name_prefix: str = None,
+ detached: bool = False,
+ worker_names: list[str] = None,
+ **kwargs,
+ ) -> None:
+ super().__init__(resource_pool=resource_pool, **kwargs)
+ self.ray_cls_with_init = ray_cls_with_init
+ self.name_prefix = get_random_string(length=6) if name_prefix is None else name_prefix
+
+ if worker_names is not None:
+ assert self._is_init_with_detached_workers
+ self._worker_names = worker_names
+
+ if self._is_init_with_detached_workers:
+ self._init_with_detached_workers(worker_names=worker_names)
+ else:
+ self._init_with_resource_pool(
+ resource_pool=resource_pool, ray_cls_with_init=ray_cls_with_init, bin_pack=bin_pack, detached=detached
+ )
+
+ if ray_cls_with_init is not None:
+ self._bind_worker_method(self.ray_cls_with_init.cls, func_generator)
+
+ def _is_worker_alive(self, worker: ActorHandle) -> bool:
+ worker_state_dict = get_actor(worker._actor_id.hex())
+ return worker_state_dict.get("state", "undefined") == "ALIVE" if worker_state_dict is not None else False
+
+ def _init_with_detached_workers(self, worker_names: list[str]) -> None:
+ workers = [ray.get_actor(name=name) for name in worker_names]
+ self._workers = workers
+ self._world_size = len(worker_names)
+
+ def _init_with_resource_pool(
+ self, resource_pool: RayResourcePool, ray_cls_with_init: RayClassWithInitArgs, bin_pack: bool, detached: bool
+ ):
+ use_gpu = resource_pool.use_gpu
+
+ strategy = "PACK"
+ if bin_pack:
+ strategy = "STRICT_PACK"
+
+ pgs = resource_pool.get_placement_groups(strategy=strategy)
+ world_size = resource_pool.world_size
+ self._world_size = world_size
+ # cia.add_kwarg("_world_size", world_size)
+ num_gpus = 1 / resource_pool.max_colocate_count
+
+ rank = -1
+ local_world_size = resource_pool.store[0]
+ for pg_idx, pg in enumerate(sort_placement_group_by_node_ip(pgs)):
+ assert local_world_size <= pg.bundle_count, f"when generating for {self.name_prefix}, for the "
+ for local_rank in range(local_world_size):
+ rank += 1
+
+ # we pass in environment variable at option so that Worker can use environment variable to set
+ env_vars = {
+ "WORLD_SIZE": str(world_size),
+ "RANK": str(rank),
+ "WG_PREFIX": self.name_prefix,
+ "WG_BACKEND": "ray",
+ "RAY_LOCAL_WORLD_SIZE": str(local_world_size),
+ "RAY_LOCAL_RANK": str(local_rank),
+ }
+ if rank != 0:
+ env_vars["MASTER_ADDR"] = self._master_addr
+ env_vars["MASTER_PORT"] = self._master_port
+
+ cia_name = type(ray_cls_with_init.cls).__name__
+ match = re.search(r"ActorClass\(([^)]+)\)", cia_name) # ray.remote(Obj) -> "ActorClass(Obj)"
+ cia_name = match.group(1) if match else cia_name # "ActorClass(Obj)" -> "Obj"
+ name = f"{self.name_prefix}{cia_name}_{pg_idx}:{local_rank}" # e.g. Worker_2:5
+
+ ray_cls_with_init.update_options({"runtime_env": {"env_vars": env_vars}, "name": name})
+
+ if detached:
+ ray_cls_with_init.update_options({"lifetime": "detached"})
+
+ # create a worker
+ worker = ray_cls_with_init(
+ placement_group=pg, placement_group_bundle_idx=local_rank, use_gpu=use_gpu, num_gpus=num_gpus
+ )
+ self._workers.append(worker)
+ self._worker_names.append(name)
+
+ if rank == 0:
+ register_center_actor = None
+ for _ in range(120):
+ if f"{self.name_prefix}_register_center" not in list_named_actors():
+ time.sleep(1)
+ else:
+ register_center_actor = ray.get_actor(f"{self.name_prefix}_register_center")
+ break
+ assert register_center_actor is not None, (
+ f"failed to get register_center_actor: {self.name_prefix}_register_center in {list_named_actors(all_namespaces=True)}"
+ )
+ rank_zero_info = ray.get(register_center_actor.get_rank_zero_info.remote())
+ self._master_addr, self._master_port = rank_zero_info["MASTER_ADDR"], rank_zero_info["MASTER_PORT"]
+ # print(f"rank_zero_info: {rank_zero_info}")
+ # print(f"master_addr: {self._master_addr}, master_port: {self._master_port}")
+
+ @property
+ def worker_names(self):
+ return self._worker_names
+
+ @classmethod
+ def from_detached(cls, worker_names=None, ray_cls_with_init=None):
+ worker_group = cls(
+ resource_pool=None, ray_cls_with_init=ray_cls_with_init, name_prefix=None, worker_names=worker_names
+ )
+ return worker_group
+
+ def spawn(self, prefix_set):
+ """
+ spawn to a dictionary of worker groups, each with a subset of method with prefix.
+
+ """
+
+ def _rebind_actor_methods(worker_group, actor_name):
+ """
+ bind the method with actor_prefix to its original name
+ """
+ prefix: str = actor_name + "_"
+ for method_name in dir(worker_group):
+ if method_name.startswith(prefix):
+ # only valid when Python >= 3.9
+ original_method_name = method_name.removeprefix(prefix)
+ method = getattr(worker_group, method_name)
+ setattr(worker_group, original_method_name, method)
+
+ new_worker_group_dict = {}
+ for prefix in prefix_set:
+ new_worker_group = self.from_detached(
+ worker_names=self._worker_names, ray_cls_with_init=self.ray_cls_with_init
+ )
+
+ _rebind_actor_methods(new_worker_group, prefix)
+ new_worker_group_dict[prefix] = new_worker_group
+ return new_worker_group_dict
+
+ def execute_rank_zero_sync(self, method_name: str, *args, **kwargs):
+ return ray.get(self.execute_rank_zero_async(method_name, *args, **kwargs))
+
+ def execute_rank_zero_async(self, method_name: str, *args, **kwargs):
+ remote_call = getattr(self._workers[0], method_name)
+ return remote_call.remote(*args, **kwargs)
+
+ def execute_rank_zero(self, method_name: str, *args, **kwargs):
+ return self.execute_rank_zero_async(method_name, *args, **kwargs)
+
+ def execute_all(self, method_name: str, *args, **kwargs):
+ return self.execute_all_async(method_name, *args, **kwargs)
+
+ def execute_all_sync(self, method_name: str, *args, **kwargs):
+ return ray.get(self.execute_all_async(method_name, *args, **kwargs))
+
+ def execute_all_async(self, method_name: str, *args, **kwargs):
+ # Here we assume that if all the parameters in args and kwargs are lists,
+ # and the lengths of all these lists are the same as len(self._workers),
+ # then we will send each element in the list to the corresponding worker.
+ # print(f"execute_all_async: method {method_name}({args}, {kwargs})")
+ length = len(self._workers)
+ if all(isinstance(arg, list) for arg in args) and all(isinstance(kwarg, list) for kwarg in kwargs.values()):
+ if all(len(arg) == length for arg in args) and all(len(kwarg) == length for kwarg in kwargs.values()):
+ # print(f"splitting args and kwargs into {length} shards")
+ result = []
+ for i in range(length):
+ sliced_args = tuple(arg[i] for arg in args)
+ sliced_kwargs = {k: v[i] for k, v in kwargs.items()}
+ remote_call = getattr(self._workers[i], method_name)
+ result.append(remote_call.remote(*sliced_args, **sliced_kwargs))
+ return result
+
+ return [getattr(worker, method_name).remote(*args, **kwargs) for worker in self._workers]
+
+ @property
+ def master_address(self):
+ return self._master_addr
+
+ @property
+ def master_port(self):
+ return self._master_port
+
+ @property
+ def workers(self):
+ return self._workers
+
+ @property
+ def world_size(self):
+ return self._world_size
+
+
+"""
+Utilities that enables creating workers inside the same ray.Actor,
+with code written in separate ray.Actors.
+"""
+
+
+def _bind_workers_method_to_parent(cls, key, user_defined_cls):
+ """
+ Binds the methods of each worker to the WorkerDict.
+ Note that we only bind public methods that are decorated by register
+ """
+ for method_name in dir(user_defined_cls):
+ try:
+ method = getattr(user_defined_cls, method_name)
+ assert callable(method), f"{method_name} in {user_defined_cls} is not callable"
+ except Exception:
+ # if it is a property, it will fail because Class doesn't have instance property
+ continue
+
+ if hasattr(method, MAGIC_ATTR):
+
+ def generate_function(name):
+ def func(self, *args, **kwargs):
+ # dispatch to the actual worker
+ return getattr(self.worker_dict[key], name)(*args, **kwargs)
+
+ return func
+
+ func = generate_function(method_name)
+ # pass MAGIC_ATTR for outer worker group
+ setattr(func, MAGIC_ATTR, getattr(method, MAGIC_ATTR))
+ try:
+ method_name_with_prefix = key + "_" + method_name
+ setattr(cls, method_name_with_prefix, func)
+ # print(f'Binding {method_name_with_prefix}')
+ except Exception:
+ raise ValueError(f"Fail to set method_name {method_name}")
+
+
+def _unwrap_ray_remote(cls):
+ if hasattr(cls, "__ray_actor_class__"):
+ cls = cls.__ray_actor_class__
+ return cls
+
+
+def create_colocated_worker_cls(class_dict: dict[str, RayClassWithInitArgs]):
+ """
+ This function should return a class instance that delegates the calls to every
+ cls in cls_dict
+ """
+ cls_dict = {}
+ init_args_dict = {}
+ worker_cls = None
+ for key, cls in class_dict.items():
+ if worker_cls is None:
+ worker_cls = cls.cls.__ray_actor_class__.__base__
+ else:
+ assert worker_cls == cls.cls.__ray_actor_class__.__base__, (
+ "the worker class should be the same when share the same process"
+ )
+ cls_dict[key] = cls.cls
+ init_args_dict[key] = {"args": cls.args, "kwargs": cls.kwargs}
+
+ assert cls_dict.keys() == init_args_dict.keys()
+
+ # TODO: create a class with customizable name
+ class WorkerDict(worker_cls):
+ def __init__(self):
+ super().__init__()
+ self.worker_dict = {}
+ for key, user_defined_cls in cls_dict.items():
+ user_defined_cls = _unwrap_ray_remote(user_defined_cls)
+ # directly instantiate the class without remote
+ with patch.dict(os.environ, {"DISABLE_WORKER_INIT": "1"}):
+ self.worker_dict[key] = user_defined_cls(
+ *init_args_dict[key].get("args", ()), **init_args_dict[key].get("kwargs", {})
+ )
+
+ # now monkey-patch the methods from inner class to WorkerDict
+ for key, user_defined_cls in cls_dict.items():
+ user_defined_cls = _unwrap_ray_remote(user_defined_cls)
+ _bind_workers_method_to_parent(WorkerDict, key, user_defined_cls)
+
+ remote_cls = ray.remote(WorkerDict)
+ remote_cls = RayClassWithInitArgs(cls=remote_cls)
+ return remote_cls
diff --git a/code/verl/trainer/__init__.py b/code/verl/trainer/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ce90c5eb352d85c59105c0dc85b5f1dd576f095
--- /dev/null
+++ b/code/verl/trainer/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2024 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.
diff --git a/code/verl/trainer/config.py b/code/verl/trainer/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..8916f64a9964b62c60c033d635a1313fe7321f10
--- /dev/null
+++ b/code/verl/trainer/config.py
@@ -0,0 +1,321 @@
+# Copyright 2024 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.
+"""
+PPO config
+"""
+
+import os
+from dataclasses import asdict, dataclass, field, fields, is_dataclass
+from typing import Optional, Tuple
+
+from ..utils.multimodal_contract import normalize_video_source_mode
+from ..utils.py_functional import get_abs_path
+from ..workers.config import WorkerConfig
+
+
+def recursive_post_init(dataclass_obj):
+ if hasattr(dataclass_obj, "post_init"):
+ dataclass_obj.post_init()
+
+ for attr in fields(dataclass_obj):
+ if is_dataclass(getattr(dataclass_obj, attr.name)):
+ recursive_post_init(getattr(dataclass_obj, attr.name))
+
+
+@dataclass
+class DataConfig:
+ train_files: str = ""
+ val_files: str = ""
+ prompt_key: str = "prompt"
+ answer_key: str = "answer"
+ image_key: str = "images"
+ video_key: str = "videos"
+ image_dir: Optional[str] = None
+ video_fps: float = 2.0
+ video_max_frames: int = 128
+ max_prompt_length: int = 512
+ max_response_length: int = 512
+ rollout_batch_size: int = 512
+ mini_rollout_batch_size: Optional[int] = None
+ val_batch_size: int = -1
+ format_prompt: Optional[str] = None
+ override_chat_template: Optional[str] = None
+ enable_thinking: bool = False
+ """forwarded to the tokenizer/processor chat template; False preserves no-CoT runs"""
+ response_prefix: str = ""
+ """literal assistant prefix appended after the generation prompt, e.g. '\\n'"""
+ shuffle: bool = True
+ seed: int = 1
+ min_pixels: Optional[int] = 262144
+ max_pixels: Optional[int] = 4194304
+ image_min_pixels: Optional[int] = None
+ image_max_pixels: Optional[int] = None
+ video_min_pixels: Optional[int] = None
+ video_max_pixels: Optional[int] = None
+ video_total_pixels: Optional[int] = None
+ val_video_fps: Optional[float] = None
+ val_video_max_frames: Optional[int] = None
+ val_video_min_pixels: Optional[int] = None
+ val_video_max_pixels: Optional[int] = None
+ val_video_total_pixels: Optional[int] = None
+ filter_overlong_prompts: bool = True
+ filter_overlong_prompts_workers: int = 16
+ use_preprocessed_videos: bool = True
+ """deprecated compatibility flag; prefer video_source_mode"""
+ video_source_mode: Optional[str] = None
+ """video source policy: prefer_preprocessed, preprocessed_only, realtime_only"""
+ preprocessed_video_dir: Optional[str] = None
+ """directory containing training preprocessed video files (.pt)"""
+ val_preprocessed_video_dir: Optional[str] = None
+ """directory containing validation preprocessed video files (.pt); defaults to preprocessed_video_dir"""
+ val_video_source_mode: Optional[str] = None
+ """validation video source policy; defaults to video_source_mode"""
+ inline_video_tensors: bool = False
+ """if True, decode the video once in the dataset and pass the decoded frames/metadata
+ inline through multi_modal_data so vLLM rollout and FSDP worker forward passes do
+ not re-decode the same mp4 (or reload the same .pt). Saves CPU at the cost of
+ larger pickled batches; recommended for realtime-decode runs."""
+ group_by_task: bool = False
+ """if True, each batch contains samples from a single task type only
+ (determined by the `group_by_task_key` field in the JSONL). Avoids mixing
+ modalities (image vs video) within a batch and concentrates gradients."""
+ group_by_task_key: str = "problem_type"
+ """JSONL field name used to identify the task type for task-grouped batching."""
+ dataloader_num_workers: int = 8
+ """number of subprocesses used by the training/validation StatefulDataLoader.
+ Controls how many videos are decoded in parallel per training step."""
+
+ def post_init(self):
+ self.image_dir = get_abs_path(self.image_dir, prompt="Image directory")
+ self.format_prompt = get_abs_path(self.format_prompt, prompt="Format prompt file")
+ self.override_chat_template = get_abs_path(self.override_chat_template, prompt="Chat template file")
+ self.preprocessed_video_dir = get_abs_path(self.preprocessed_video_dir, prompt="Preprocessed video directory")
+ self.val_preprocessed_video_dir = get_abs_path(self.val_preprocessed_video_dir, prompt="Validation preprocessed video directory")
+ if self.image_min_pixels is None:
+ self.image_min_pixels = self.min_pixels
+ if self.image_max_pixels is None:
+ self.image_max_pixels = self.max_pixels
+ if self.video_min_pixels is None:
+ self.video_min_pixels = self.min_pixels
+ if self.video_max_pixels is None:
+ self.video_max_pixels = self.max_pixels
+ if self.val_video_fps is None:
+ self.val_video_fps = self.video_fps
+ if self.val_video_max_frames is None:
+ self.val_video_max_frames = self.video_max_frames
+ if self.val_video_min_pixels is None:
+ self.val_video_min_pixels = self.video_min_pixels
+ if self.val_video_max_pixels is None:
+ self.val_video_max_pixels = self.video_max_pixels
+ if self.val_video_total_pixels is None:
+ self.val_video_total_pixels = self.video_total_pixels
+ if self.val_preprocessed_video_dir is None:
+ self.val_preprocessed_video_dir = self.preprocessed_video_dir
+ self.video_source_mode = normalize_video_source_mode(
+ self.video_source_mode,
+ use_preprocessed_videos=self.use_preprocessed_videos,
+ )
+ if self.val_video_source_mode is None:
+ self.val_video_source_mode = self.video_source_mode
+ else:
+ self.val_video_source_mode = normalize_video_source_mode(
+ self.val_video_source_mode,
+ use_preprocessed_videos=self.use_preprocessed_videos,
+ )
+
+
+@dataclass
+class AlgorithmConfig:
+ name: str = "grpo"
+ """Algorithm selector; the released recipes are ``grpo`` and ``orarl``."""
+ gamma: float = 1.0
+ """discount factor for ppo gae advantage estimator"""
+ lam: float = 1.0
+ """lambda value for ppo gae advantage estimator"""
+ adv_estimator: str = "grpo"
+ """advantage estimator, support `gae`, `grpo`, `reinforce_plus_plus`, `remax`, `rloo`"""
+ scale_rewards: bool = True
+ """Whether GRPO divides each group-centered outcome reward by the group's
+ reward standard deviation. True preserves the historical normalized GRPO
+ objective. False uses the TRL raw-centered objective
+ `A_i = r_i - mean_group(r)`, avoiding amplification of tiny reward gaps."""
+ disable_kl: bool = False
+ """disable reference model"""
+ use_kl_loss: bool = False
+ """use kl loss instead of kl in reward"""
+ kl_penalty: str = "kl"
+ """kl penalty type, support `kl`, `abs`, `mse`, `low_var_kl`, `full`"""
+ kl_coef: float = 1e-3
+ """kl coefficient"""
+ kl_type: str = "fixed"
+ """kl controller type, support `fixed`, `adaptive`"""
+ kl_horizon: float = 10000.0
+ """kl horizon for adaptive kl controller"""
+ kl_target: float = 0.1
+ """target kl for adaptive kl controller"""
+
+ # OraRL stages, in the order the paper applies them.
+ oracle_injection: bool = False
+ """Append one annotation-derived oracle response to each rollout group."""
+ oracle_injection_mode: str = "append"
+ """Oracle insertion mode. The released OraRL recipe requires ``append``."""
+ oracle_builder: Optional[str] = None
+ """Dotted ``module:function`` that constructs an oracle response."""
+ oracle_replace_index: int = -1
+ """Compatibility slot for non-released replacement mode."""
+ oracle_log_exclude: bool = True
+ """Exclude oracle rows from on-policy reward/accuracy logging."""
+ directional_gain: bool = False
+ """Enable the oracle-gap directional gain on on-policy utilities."""
+ directional_gain_gamma: float = 0.25
+ """Exponent for the clipped oracle-gap scale."""
+ directional_gain_positive_only: bool = True
+ """Amplify only utilities pointing toward the positive oracle."""
+ directional_gain_recenter: bool = True
+ """Re-center transformed on-policy utilities before selection."""
+ detached_oracle_advantage: bool = False
+ """Overwrite the oracle row with a detached positive anchor."""
+ detached_oracle_advantage_scale: float = 2.0
+ """Base magnitude of the detached oracle anchor."""
+ detached_oracle_use_directional_gain: bool = False
+ """Whether the policy directional gain also scales the oracle anchor."""
+ detached_oracle_match_best_ratio: float = 1.2
+ """Cap the oracle anchor relative to the strongest positive policy row."""
+ detached_oracle_match_best_min: float = 0.05
+ """Lower bound for the adaptive oracle cap."""
+ detached_oracle_match_best_max: float = 1.0
+ """Upper bound for the adaptive oracle cap."""
+ oracle_reward_gate_beta: float = 2.0
+ """Exponent for the normalized oracle reward-gap gate."""
+ selection_prune_ratio: float = 0.0
+ """Fraction of generated policy rows removed before actor backpropagation."""
+ selection_keep_oracle: bool = True
+ """Force-retain the detached oracle row."""
+ selection_positive_quota: int = 0
+ """Number of positive-advantage policy rows retained per group."""
+ selection_negative_quota: int = 0
+ """Number of negative-advantage policy rows retained per group."""
+ selection_strict_sign_balance: bool = True
+ """Select from true positive/negative buckets with deterministic fallback."""
+ post_selection_recenter: bool = False
+ """Restore zero mean over rows that actually receive gradients."""
+ post_selection_rms_match: bool = False
+ """Only downscale active RMS toward its pre-selection policy reference."""
+ post_selection_rms_min_scale: float = 0.25
+ """Lower bound for post-selection RMS downscaling."""
+
+
+@dataclass
+class TrainerConfig:
+ total_epochs: int = 15
+ """total epochs for training"""
+ max_steps: Optional[int] = None
+ """max steps for training, if specified, total_epochs is ignored"""
+ project_name: str = "orarl"
+ """project name for logger"""
+ experiment_name: str = "demo"
+ """experiment name for logger"""
+ logger: Tuple[str] = ("console", "wandb")
+ """logger type, support `console`, `mlflow`, `swanlab`, `tensorboard`, `wandb`"""
+ nnodes: int = 1
+ """number of nodes for training"""
+ n_gpus_per_node: int = 8
+ """number of gpus per node for training"""
+ critic_warmup: int = 0
+ """critic warmup steps"""
+ val_freq: int = -1
+ """validation frequency, -1 means no validation"""
+ val_before_train: bool = True
+ """validate before training"""
+ val_only: bool = False
+ """validate only, skip training"""
+ val_generations_to_log: int = 0
+ """number of generations to log for validation"""
+ save_freq: int = -1
+ """save frequency, -1 means no saving"""
+ save_limit: int = -1
+ """max number of checkpoints to save, -1 means no limit"""
+ save_model_only: bool = False
+ """save model only, no optimizer state dict"""
+ keep_optim_only_latest: bool = False
+ """when True, only the latest checkpoint keeps optimizer / extra_state / dataloader;
+ older checkpoints retained within `save_limit` are thinned down to model weights only.
+ Mutually exclusive with `save_model_only` (when `save_model_only=True` optimizer is
+ never saved at all, so this flag is a no-op). Useful for saving disk while still
+ allowing resume from the latest step."""
+ save_checkpoint_path: Optional[str] = None
+ """save checkpoint path, if not specified, use `checkpoints/project_name/experiment_name`"""
+ load_checkpoint_path: Optional[str] = None
+ """load checkpoint path"""
+ ray_timeline: Optional[str] = None
+ """file to save ray timeline"""
+ find_last_checkpoint: bool = True
+ """automatically find the last checkpoint in the save checkpoint path to resume training"""
+ keep_best_train_ckpt: bool = False
+ """When True, additionally save the model-only checkpoint at the step with the
+ highest *smoothed* training reward into a separate `best_train/` subdirectory.
+ This sidesteps the rolling `save_limit` window so a peak you discovered mid-run
+ is not evicted by later (potentially overtrained) saves. No optimizer/dataloader
+ state is kept, only model weights — purely for downstream evaluation."""
+ best_train_metric_key: str = "reward/overall"
+ """Which key in the per-step `metrics` dict to track. Common choices:
+ `reward/overall` (default; combines IoU + format), `reward/iou`,
+ `critic/rewards/mean`."""
+ best_train_smooth_window: int = 5
+ """Number of recent steps to average for the best-train signal. Reduces single-
+ step noise so we lock onto a true plateau rather than a lucky spike. Set to 1
+ to disable smoothing."""
+ best_train_min_step: int = 10
+ """Don't track best-train until this many steps have elapsed (skip warmup
+ where reward jumps quickly and the highest single value is nearly meaningless
+ for picking a good policy)."""
+
+ def post_init(self):
+ if self.save_checkpoint_path is None:
+ self.save_checkpoint_path = os.path.join("checkpoints", self.project_name, self.experiment_name)
+
+ self.save_checkpoint_path = os.path.abspath(self.save_checkpoint_path) # may be not exist
+ self.load_checkpoint_path = get_abs_path(self.load_checkpoint_path, prompt="Model checkpoint")
+
+
+@dataclass
+class PPOConfig:
+ data: DataConfig = field(default_factory=DataConfig)
+ worker: WorkerConfig = field(default_factory=WorkerConfig)
+ algorithm: AlgorithmConfig = field(default_factory=AlgorithmConfig)
+ trainer: TrainerConfig = field(default_factory=TrainerConfig)
+
+ def post_init(self):
+ self.worker.rollout.prompt_length = self.data.max_prompt_length
+ self.worker.rollout.response_length = self.data.max_response_length
+ self.worker.rollout.trust_remote_code = self.worker.actor.model.trust_remote_code
+ self.worker.actor.disable_kl = self.algorithm.disable_kl
+ self.worker.actor.use_kl_loss = self.algorithm.use_kl_loss
+ self.worker.actor.kl_penalty = self.algorithm.kl_penalty
+ self.worker.actor.kl_coef = self.algorithm.kl_coef
+ # The actor sizes its per-rank mini-batch from the post-selection row
+ # count k = floor(n*(1-P)) per group rather than the full n.
+ self.worker.actor.selection_prune_ratio = (
+ float(self.algorithm.selection_prune_ratio)
+ if str(self.algorithm.name).strip().lower() == "orarl"
+ else 0.0
+ )
+
+ def deep_post_init(self):
+ recursive_post_init(self)
+
+ def to_dict(self):
+ from .orarl_config import public_config_dict
+
+ return public_config_dict(self)
diff --git a/code/verl/trainer/core_algos.py b/code/verl/trainer/core_algos.py
new file mode 100644
index 0000000000000000000000000000000000000000..f513ac5bf336d5657e62fbe6ddbc64d8d51ae2b4
--- /dev/null
+++ b/code/verl/trainer/core_algos.py
@@ -0,0 +1,760 @@
+# Copyright 2022 The HuggingFace Team
+# Copyright 2024 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.
+"""
+Core functions to implement PPO algorithms.
+The function implemented in this file should be used by trainer with different distributed strategies to
+implement PPO
+"""
+
+from abc import ABC, abstractmethod
+from collections import defaultdict
+from enum import Enum
+from typing import TYPE_CHECKING, Any, Literal, Optional
+
+import numpy as np
+import torch
+import torch.nn.functional as F
+
+from ..utils import torch_functional as VF
+
+
+if TYPE_CHECKING:
+ from .config import AlgorithmConfig
+
+
+class KLController(ABC):
+ kl_coef: float
+ """KL coefficient."""
+
+ @abstractmethod
+ def update(self, current_kl: float, n_steps: int):
+ """Update kl_coef according to current KL."""
+ ...
+
+
+class AdaptiveKLController(KLController):
+ """Adaptive KL controller described in: https://arxiv.org/pdf/1909.08593.pdf
+
+ Copied from https://github.com/huggingface/trl/blob/v0.11.0/trl/trainer/utils.py#L54"""
+
+ def __init__(self, init_kl_coef: float, target_kl: float, horizon: float):
+ self.kl_coef = init_kl_coef
+ self.target = target_kl
+ self.horizon = horizon
+
+ def update(self, current_kl: float, n_steps: int):
+ target = self.target
+ proportional_error = np.clip(current_kl / target - 1, -0.2, 0.2)
+ mult = 1 + proportional_error * n_steps / self.horizon
+ self.kl_coef *= mult
+
+
+class FixedKLController(KLController):
+ """Fixed KL controller.
+
+ Copeid from https://github.com/huggingface/trl/blob/v0.11.0/trl/trainer/utils.py#L72"""
+
+ def __init__(self, init_kl_coef: float):
+ self.kl_coef = init_kl_coef
+
+ def update(self, current_kl: float, n_steps: int):
+ pass
+
+
+class AdvantageEstimator(str, Enum):
+ """
+ Using an enumeration class to avoid spelling errors in adv_estimator
+ """
+
+ GAE = "gae"
+ GRPO = "grpo"
+ GRPO_PASSK = "grpo_passk"
+ REINFORCE_PLUS_PLUS = "reinforce_plus_plus"
+ REMAX = "remax"
+ RLOO = "rloo"
+
+
+ADV_ESTIMATOR_MAP: dict[str, Any] = {}
+
+
+def get_kl_controller(algorithm_config: "AlgorithmConfig") -> KLController:
+ """Adapted from https://github.com/huggingface/trl/blob/v0.11.0/trl/trainer/ppo_trainer.py#L319"""
+ if algorithm_config.kl_type == "fixed":
+ kl_ctrl = FixedKLController(init_kl_coef=algorithm_config.kl_coef)
+ elif algorithm_config.kl_type == "adaptive":
+ assert algorithm_config.kl_horizon > 0, f"horizon must be larger than 0. Got {algorithm_config.kl_horizon}."
+ kl_ctrl = AdaptiveKLController(
+ init_kl_coef=algorithm_config.kl_coef,
+ target_kl=algorithm_config.kl_target,
+ horizon=algorithm_config.kl_horizon,
+ )
+ else:
+ raise ValueError(f"Unknown kl type: {algorithm_config.kl_type}.")
+
+ return kl_ctrl
+
+
+def register_adv_estimator(name: AdvantageEstimator):
+ """Decorator to register a advantage estimator function with a given name."""
+
+ def decorator(fn):
+ wrapped_fn = torch.no_grad()(fn)
+ ADV_ESTIMATOR_MAP[getattr(name, "value", name)] = wrapped_fn
+ return wrapped_fn
+
+ return decorator
+
+
+def compute_advantage_return(name: AdvantageEstimator, **kwargs) -> tuple[torch.Tensor, torch.Tensor]:
+ """Compute advantage and return for a given advantage estimator."""
+ return ADV_ESTIMATOR_MAP[getattr(name, "value", name)](**kwargs)
+
+
+@register_adv_estimator(AdvantageEstimator.GAE)
+def compute_gae_advantage_return(
+ token_level_rewards: torch.Tensor,
+ values: torch.Tensor,
+ response_mask: torch.Tensor,
+ gamma: torch.Tensor,
+ lam: torch.Tensor,
+ **kwargs,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """Adapted from https://github.com/huggingface/trl/blob/v0.16.0/trl/trainer/ppo_trainer.py#L513
+
+ Args:
+ token_level_rewards: `(torch.Tensor)`
+ shape: (bs, response_length)
+ values: `(torch.Tensor)`
+ shape: (bs, response_length)
+ response_mask: `(torch.Tensor)`
+ shape: (bs, response_length). The token after eos tokens have mask zero.
+ gamma: `(float)`
+ discounted factor used in RL
+ lam: `(float)`
+ lambda value when computing Generalized Advantage Estimation (https://arxiv.org/abs/1506.02438)
+
+ Returns:
+ advantages: `(torch.Tensor)`
+ shape: (bs, response_length)
+ returns: `(torch.Tensor)`
+ shape: (bs, response_length)
+
+ """
+ nextvalues = 0
+ lastgaelam = 0
+ advantages_reversed = []
+ gen_len = token_level_rewards.shape[-1]
+ for t in reversed(range(gen_len)):
+ delta = token_level_rewards[:, t] + gamma * nextvalues - values[:, t]
+ gaelam = delta + gamma * lam * lastgaelam
+
+ if response_mask[:, t]: # skip values and TD-error on observation tokens
+ nextvalues = values[:, t]
+ lastgaelam = gaelam
+
+ advantages_reversed.append(lastgaelam)
+
+ advantages = torch.stack(advantages_reversed[::-1], dim=1)
+ returns = advantages + values
+ advantages = VF.masked_whiten(advantages, response_mask)
+ return advantages, returns
+
+
+@register_adv_estimator(AdvantageEstimator.GRPO)
+def compute_grpo_outcome_advantage(
+ token_level_rewards: torch.Tensor, response_mask: torch.Tensor, index: torch.Tensor, eps: float = 1e-6, **kwargs
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """
+ Compute advantage for GRPO, operating only on Outcome reward (with only one scalar reward for each response).
+
+ ``scale_rewards=True`` uses standard GRPO whitening:
+ A_i = (r_i - μ) / (σ + ε)
+ ``scale_rewards=False`` uses raw-centered advantages:
+ A_i = r_i - μ
+
+ Oracle-aware statistics (active when ``is_oracle_row`` is supplied and
+ ``oracle_excluded_baseline=True``):
+
+ Both μ and σ come from the on-policy rollouts only. Excluding the
+ oracle from μ keeps the baseline action-independent and an honest
+ estimate of E_{y~π_θ}[R]; a perfect-reward oracle inside μ would push
+ the best on-policy rollout to a negative advantage on hard prompts and
+ corrupt any sign-aware selector. Excluding it from σ stops it from
+ inflating the denominator and shrinking on-policy gradients.
+
+ On-policy σ is numerically unsafe on its own: when every on-policy
+ reward collapses to one value (typical when a shaping floor swallows
+ all failures), σ_op is exactly 0 and the oracle advantage diverges.
+ Groups with σ_op < SIGMA_OP_FALLBACK_THRESHOLD therefore fall back to
+ the full-group σ, which stays positive because the oracle is a
+ high-reward outlier.
+
+ Args:
+ token_level_rewards, response_mask, index, eps: standard.
+ is_oracle_row (kwargs): np.ndarray[bool] of shape (bs,); True marks the
+ annotation-derived oracle row.
+ oracle_excluded_baseline (kwargs): build μ and σ from on-policy rows.
+ directional_gain (kwargs): scale on-policy utilities by the clipped
+ oracle-gap ratio (σ_g/σ_op)^γ.
+ directional_gain_recenter (kwargs): after a positive-only gain,
+ subtract the transformed on-policy group mean, preserving the
+ directional preference while restoring a zero-mean group.
+ """
+ is_oracle_row = kwargs.get("is_oracle_row", None)
+ scale_rewards = bool(kwargs.get("scale_rewards", True))
+ exclude_oracle = bool(kwargs.get("oracle_excluded_baseline", False))
+ # The oracle-gap gain multiplies the base advantage by
+ # clip((σ_g/σ_op)^γ, 1, 4). σ_g includes the oracle and therefore measures
+ # how far it lies outside the on-policy reward distribution, so the gain
+ # grows exactly on the groups the policy has not solved. It requires the
+ # oracle-excluded baseline μ_op; σ_op stays available even in raw mode.
+ use_gain = bool(kwargs.get("directional_gain", False))
+ gain_gamma = float(kwargs.get("directional_gain_gamma", 0.25))
+ gain_pos_only = bool(kwargs.get("directional_gain_positive_only", False))
+ gain_recenter = bool(kwargs.get("directional_gain_recenter", False))
+ if use_gain:
+ exclude_oracle = True
+ # Cap (σ_g/σ_op)^γ so a near-degenerate σ_op cannot blow the gain up.
+ GAIN_MAX: float = 4.0
+
+ scores = token_level_rewards.sum(dim=-1)
+ bsz = scores.shape[0]
+
+ # Build two per-group score lists in a single pass:
+ # id2score_full = ALL rows (σ_op fallback source, and vanilla GRPO
+ # mean/std when exclude_oracle=False).
+ # id2score_op = on-policy only (μ and σ source when exclude_oracle).
+ id2score_full: dict[Any, list[torch.Tensor]] = defaultdict(list)
+ id2score_op: dict[Any, list[torch.Tensor]] = defaultdict(list)
+ for i in range(bsz):
+ id2score_full[index[i]].append(scores[i])
+ if exclude_oracle and is_oracle_row is not None and bool(is_oracle_row[i]):
+ continue
+ id2score_op[index[i]].append(scores[i])
+
+ # 1e-3 is far below any realistic reward std (≥ ~0.05 even on very hard
+ # prompts), so the σ_g fallback only fires on truly degenerate groups.
+ SIGMA_OP_FALLBACK_THRESHOLD: float = 1e-3
+ sigma_op_fallback_count = 0
+
+ id2mean: dict[Any, torch.Tensor] = {}
+ id2std: dict[Any, torch.Tensor] = {}
+ # Per-group gain g_idx = clip((σ_g/σ_op)^γ, ≤ GAIN_MAX), applied to
+ # on-policy rows only. Empty unless use_gain.
+ id2gain: dict[Any, torch.Tensor] = {}
+ for idx, full_lst in id2score_full.items():
+ if not exclude_oracle:
+ assert len(full_lst) > 1, "GRPO needs rollout.n > 1."
+ id2mean[idx] = torch.mean(torch.tensor(full_lst))
+ id2std[idx] = torch.std(torch.tensor(full_lst))
+ continue
+
+ op_lst = id2score_op.get(idx, [])
+ # ---- Mean source: on-policy ONLY (with edge-case fallbacks) ----
+ if len(op_lst) == 0:
+ # Pathological: the group holds only the oracle row. The append
+ # pipeline keeps all n on-policy rollouts, so this cannot happen.
+ id2mean[idx] = torch.zeros((), dtype=scores.dtype, device=scores.device)
+ elif len(op_lst) == 1:
+ # Single on-policy row → its own mean → its own advantage ≈ 0.
+ id2mean[idx] = op_lst[0]
+ else:
+ id2mean[idx] = torch.mean(torch.tensor(op_lst))
+
+ # ---- Std sources ----
+ # σ_g (full group, oracle included) is always computed: it is the
+ # degenerate-group fallback and also feeds the oracle-gap gain. σ_op
+ # (on-policy only) is the base whitening std.
+ sigma_g = (
+ torch.std(torch.tensor(full_lst))
+ if len(full_lst) >= 2
+ else torch.zeros((), dtype=scores.dtype, device=scores.device)
+ )
+ sigma_op = torch.std(torch.tensor(op_lst)) if len(op_lst) >= 2 else None
+ op_degenerate = sigma_op is None or float(sigma_op.item()) < SIGMA_OP_FALLBACK_THRESHOLD
+
+ if op_degenerate:
+ id2std[idx] = sigma_g
+ sigma_op_fallback_count += 1
+ else:
+ id2std[idx] = sigma_op
+
+ # ---- Oracle-gap gain (σ_g/σ_op)^γ ----
+ # Scaled mode neutralizes the gain when σ_op degenerates, because its
+ # whitening denominator has already fallen back to σ_g. Raw mode can
+ # follow the clipped-gain formula even for tiny σ_op: eps and the upper
+ # cap keep g finite, while the unwhitened centered reward still tends to
+ # zero with σ_op.
+ if use_gain:
+ if op_degenerate and scale_rewards:
+ id2gain[idx] = torch.ones((), dtype=scores.dtype, device=scores.device)
+ else:
+ sigma_op_for_gain = (
+ sigma_op
+ if sigma_op is not None
+ else torch.zeros((), dtype=scores.dtype, device=scores.device)
+ )
+ ratio = sigma_g / (sigma_op_for_gain + eps)
+ id2gain[idx] = torch.clamp(
+ ratio ** gain_gamma,
+ min=1.0,
+ max=GAIN_MAX,
+ )
+
+ gain_on_policy = 0
+ gain_amplified = 0
+ for i in range(bsz):
+ m = id2mean.get(index[i])
+ s = id2std.get(index[i])
+ if m is None:
+ continue
+ centered_reward = scores[i] - m
+ adv = centered_reward / (s + eps) if scale_rewards else centered_reward
+ if use_gain:
+ g = id2gain.get(index[i], None)
+ # Amplify ON-POLICY rows only. The oracle row keeps its raw
+ # (r_oracle-μ_op)/σ_op here; it is overwritten downstream by the
+ # detached anchor. When positive_only, amplify only improving
+ # (adv > 0, toward-oracle) rows; negatives stay at base scale.
+ if g is not None and not (is_oracle_row is not None and bool(is_oracle_row[i])):
+ gain_on_policy += 1
+ if (not gain_pos_only) or (adv > 0):
+ adv = adv * g
+ gain_amplified += 1
+ scores[i] = adv
+
+ # Positive-only gain is an asymmetric utility transform. Without this
+ # second baseline, each on-policy group has a positive sum
+ # (g - 1) * sum(max(A_base, 0)),
+ # which stacks an unconditional positive bias on top of oracle anchoring and
+ # sign-balanced selection. Re-centering preserves ranking and the boosted
+ # positive-vs-negative margin while restoring mean_op(A)=0.
+ recenter_shifts: list[float] = []
+ if use_gain and gain_recenter:
+ id2transformed_op: dict[Any, list[torch.Tensor]] = defaultdict(list)
+ for i in range(bsz):
+ if is_oracle_row is not None and bool(is_oracle_row[i]):
+ continue
+ id2transformed_op[index[i]].append(scores[i])
+
+ id2recenter_shift: dict[Any, torch.Tensor] = {}
+ for idx, transformed in id2transformed_op.items():
+ if transformed:
+ shift = torch.mean(torch.stack(transformed))
+ id2recenter_shift[idx] = shift
+ recenter_shifts.append(abs(float(shift.item())))
+
+ for i in range(bsz):
+ if is_oracle_row is not None and bool(is_oracle_row[i]):
+ continue
+ shift = id2recenter_shift.get(index[i])
+ if shift is not None:
+ scores[i] = scores[i] - shift
+
+ # Stash the fallback count on a side-channel kwarg if the caller passed an
+ # empty dict for telemetry; this is opt-in to keep the function API clean.
+ telemetry = kwargs.get("_telemetry_out", None)
+ if telemetry is not None:
+ telemetry["scale_rewards"] = float(scale_rewards)
+ telemetry["sigma_op_fallback_count"] = sigma_op_fallback_count
+ if use_gain and id2gain:
+ gains = [float(g.item()) for g in id2gain.values()]
+ telemetry["directional_gain_mean"] = sum(gains) / len(gains)
+ telemetry["directional_gain_max"] = max(gains)
+ telemetry["directional_gain_positive_only"] = float(gain_pos_only)
+ telemetry["directional_gain_recenter"] = float(gain_recenter)
+ if recenter_shifts:
+ telemetry["directional_gain_recenter_abs_shift_mean"] = (
+ sum(recenter_shifts) / len(recenter_shifts)
+ )
+ telemetry["directional_gain_recenter_abs_shift_max"] = max(recenter_shifts)
+ if gain_on_policy > 0:
+ telemetry["directional_gain_amplified_frac"] = (
+ gain_amplified / gain_on_policy
+ )
+
+ returns = scores.unsqueeze(-1) * response_mask
+ return returns, returns
+
+
+@register_adv_estimator(AdvantageEstimator.GRPO_PASSK)
+def compute_grpo_passk_outcome_advantage(
+ token_level_rewards: torch.Tensor, response_mask: torch.Tensor, index: torch.Tensor, eps: float = 1e-6, **kwargs
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """
+ Compute advantage for Pass@k using a GRPO-style outcome reward formulation.
+ Only the best response per group gets a non-zero advantage: r_max - r_second_max.
+
+ Implemented as described in https://arxiv.org/abs/2503.19595.
+
+ Args:
+ token_level_rewards: `(torch.Tensor)`
+ shape: (bs, response_length)
+ response_mask: `(torch.Tensor)`
+ shape: (bs, response_length)
+ index: `(torch.Tensor)`
+ shape: (bs,)
+ eps: `(float)`
+ epsilon value to avoid division by zero
+
+ Returns:
+ advantages: `(torch.Tensor)`
+ shape: (bs, response_length)
+ returns: `(torch.Tensor)`
+ shape: (bs, response_length)
+
+ """
+ scores = token_level_rewards.sum(dim=-1)
+ advantages = torch.zeros_like(scores)
+ id2score = defaultdict(list)
+ id2indices = defaultdict(list)
+
+ bsz = scores.shape[0]
+ for i in range(bsz):
+ id2score[index[i]].append(scores[i])
+ id2indices[index[i]].append(i)
+
+ for idx in id2score:
+ assert len(id2score[idx]) > 1, "GRPO needs rollout.n > 1."
+ rewards = torch.tensor(id2score[idx])
+ topk, topk_idx = torch.topk(rewards, k=2)
+ r_max, r_second_max = topk[0], topk[1]
+ i_max = id2indices[idx][topk_idx[0]]
+ advantages[i_max] = (r_max - r_second_max) / (torch.std(torch.tensor(id2score[idx])) + eps)
+
+ returns = advantages.unsqueeze(-1) * response_mask
+ return returns, returns
+
+
+@register_adv_estimator(AdvantageEstimator.RLOO)
+def compute_rloo_outcome_advantage(
+ token_level_rewards: torch.Tensor, response_mask: torch.Tensor, index: torch.Tensor, **kwargs
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """
+ Compute advantage for RLOO based on https://arxiv.org/abs/2402.14740
+
+ Args:
+ token_level_rewards: `(torch.Tensor)`
+ shape: (bs, response_length)
+ response_mask: `(torch.Tensor)`
+ shape: (bs, response_length)
+ index: `(torch.Tensor)`
+ shape: (bs,)
+
+ Returns:
+ advantages: `(torch.Tensor)`
+ shape: (bs, response_length)
+ returns: `(torch.Tensor)`
+ shape: (bs, response_length)
+
+ """
+ scores = token_level_rewards.sum(dim=-1)
+
+ id2score = defaultdict(list)
+ id2sum = {}
+ bsz = scores.shape[0]
+ for i in range(bsz):
+ id2score[index[i]].append(scores[i])
+
+ for idx in id2score:
+ id2sum[idx] = torch.sum(torch.tensor(id2score[idx]))
+
+ for i in range(bsz):
+ sample_num = len(id2score[index[i]])
+ assert sample_num > 1, "RLOO needs rollout.n > 1."
+ baseline = (id2sum[index[i]] - scores[i]) / (sample_num - 1)
+ scores[i] = scores[i] - baseline
+
+ returns = scores.unsqueeze(-1) * response_mask
+ return returns, returns
+
+
+@register_adv_estimator(AdvantageEstimator.REINFORCE_PLUS_PLUS)
+def compute_reinforce_plus_plus_outcome_advantage(
+ token_level_rewards: torch.Tensor, response_mask: torch.Tensor, gamma: torch.Tensor, **kwargs
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """
+ Compute advantage for REINFORCE++.
+ This implementation is based on the paper: https://arxiv.org/abs/2501.03262
+
+ Args:
+ token_level_rewards: `(torch.Tensor)`
+ shape: (bs, response_length)
+ response_mask: `(torch.Tensor)`
+ shape: (bs, response_length)
+
+ Returns:
+ advantages: `(torch.Tensor)`
+ shape: (bs, response_length)
+ returns: `(torch.Tensor)`
+ shape: (bs, response_length)
+
+ """
+ returns = torch.zeros_like(token_level_rewards)
+ running_return = 0
+ for t in reversed(range(token_level_rewards.shape[1])):
+ running_return = token_level_rewards[:, t] + gamma * running_return
+ returns[:, t] = running_return
+ # Reset after EOS
+ running_return = running_return * response_mask[:, t]
+
+ advantages = VF.masked_whiten(returns, response_mask)
+ return advantages, returns
+
+
+@register_adv_estimator(AdvantageEstimator.REMAX)
+def compute_remax_outcome_advantage(
+ token_level_rewards: torch.Tensor, reward_baselines: torch.Tensor, response_mask: torch.Tensor, **kwargs
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """
+ Compute advantage for ReMax, operating only on Outcome reward
+ This implementation is based on the paper: https://arxiv.org/abs/2310.10505
+
+ (with only one scalar reward for each response).
+ Args:
+ token_level_rewards: `(torch.Tensor)`
+ shape: (bs, response_length)
+ reward_baselines: `(torch.Tensor)`
+ shape: (bs,)
+ response_mask: `(torch.Tensor)`
+ shape: (bs, response_length)
+
+ Returns:
+ advantages: `(torch.Tensor)`
+ shape: (bs, response_length)
+ returns: `(torch.Tensor)`
+ shape: (bs, response_length)
+
+ """
+ advantages = (token_level_rewards.sum(dim=-1) - reward_baselines) * response_mask
+ returns = (token_level_rewards * response_mask).flip(dims=(-1,)).cumsum(dim=-1).flip(dims=(-1,))
+ return advantages, returns
+
+
+def compute_rewards(
+ token_level_scores: torch.Tensor,
+ log_probs: torch.Tensor,
+ ref_log_probs: torch.Tensor,
+ kl_ratio: float,
+) -> torch.Tensor:
+ kl = log_probs - ref_log_probs
+ return token_level_scores - kl * kl_ratio
+
+
+def average_loss(
+ values: torch.Tensor, mask: torch.Tensor, mode: Literal["token", "seq"], eps: float = 1e-8
+) -> torch.Tensor:
+ """Average the policy loss.
+
+ Args:
+ values: `(torch.Tensor)`
+ shape: (bs, response_length)
+ mask: `(torch.Tensor)`
+ shape: (bs, response_length)
+ mode: `(Literal["token", "seq"])`
+ "token": average the loss in the whole batch
+ "seq": average the loss in each sequence then average the mean of the means
+ eps: `(float)`
+ epsilon value
+
+ Returns:
+ loss: `a scalar torch.Tensor`
+ """
+ if mode == "token":
+ return VF.masked_mean(values, mask, eps=eps)
+ elif mode == "seq":
+ return ((values * mask).sum(-1) / (mask.sum(-1) + eps)).mean()
+ else:
+ raise NotImplementedError(f"Unknown mode: {mode}.")
+
+
+def compute_policy_loss(
+ old_log_probs: torch.Tensor,
+ log_probs: torch.Tensor,
+ advantages: torch.Tensor,
+ response_mask: torch.Tensor,
+ clip_ratio_low: float,
+ clip_ratio_high: float,
+ clip_ratio_dual: float,
+ loss_type: Literal["default", "gspo", "gspo_token", "cispo"],
+ loss_avg_mode: Literal["token", "seq"],
+ **kwargs,
+) -> tuple[torch.Tensor, dict[str, float]]:
+ """Compute the clipped policy objective and related metrics for PPO.
+
+ Adapted from https://github.com/huggingface/trl/blob/v0.15.0/trl/trainer/ppo_trainer.py#L568
+
+ Args:
+ old_log_prob: `(torch.Tensor)`
+ shape: (bs, response_length)
+ log_prob: `(torch.Tensor)`
+ shape: (bs, response_length)
+ advantages: `(torch.Tensor)`
+ shape: (bs, response_length)
+ response_mask: `(torch.Tensor)`
+ shape: (bs, response_length)
+ clip_ratio_low: (float)
+ The lower clip range used in PPO. See https://arxiv.org/abs/1707.06347
+ clip_ratio_high: (float)
+ The higher clip range used in DAPO. See https://arxiv.org/pdf/2503.14476
+ clip_ratio_dual: (float)
+ The dual clip range used in Dual-clip PPO. See https://arxiv.org/pdf/1912.09729
+ loss_avg_mode: (Literal["token", "seq"])
+ "token": average the loss in the whole batch
+ "seq": average the loss in each sequence then average the mean of the means
+
+ Returns:
+ pg_loss: `a scalar torch.Tensor`
+ policy gradient loss computed via PPO
+ pg_clipfrac_higher: (float)
+ a float number indicating the fraction of policy gradient loss being clipped to a higher value
+ pg_clipfrac_lower: (float)
+ a float number indicating the fraction of policy gradient loss being clipped to a lower value
+ ppo_kl: (float)
+ a float number indicating the mean KL divergence between the old policy and the new policy
+ entropy_loss: (float)
+ a float number indicating the mean entropy loss
+
+ """
+ negative_approx_kl = log_probs - old_log_probs
+ if loss_type in ["gspo", "gspo_token"]:
+ # compute sequence-level importance ratio
+ negative_approx_kl_in_seq = VF.masked_mean(negative_approx_kl, response_mask, dim=-1)
+ # combined ratio at token level
+ if loss_type == "gspo_token":
+ log_importance_ratio = negative_approx_kl_in_seq.detach().unsqueeze(-1) + log_probs - log_probs.detach()
+ else:
+ log_importance_ratio = negative_approx_kl_in_seq.unsqueeze(-1) * response_mask
+ else:
+ log_importance_ratio = negative_approx_kl
+
+ # clamp the ratio before exp to avoid nan grad
+ # see: https://github.com/pytorch/pytorch/issues/10729
+ ratio = torch.exp(torch.clamp(log_importance_ratio, -20.0, 20.0))
+ clipped_ratio = torch.exp(
+ torch.clamp(log_importance_ratio, np.log(1.0 - clip_ratio_low), np.log(1.0 + clip_ratio_high))
+ )
+
+ # pg metrics
+ metrics = {"ppo_kl": -negative_approx_kl}
+ # use negative log probs as an estimator of entropy loss
+ metrics["entropy_loss"] = average_loss(-log_probs, response_mask, mode=loss_avg_mode)
+
+ if loss_type == "cispo":
+ final_pg_loss = -advantages * log_probs * clipped_ratio.detach()
+ else:
+ pg_loss = -advantages * ratio # -ratio * A
+ pg_loss2 = -advantages * clipped_ratio # -clip(ratio, 1-clip_low, 1+clip_high) * A
+ pg_loss3 = -advantages * clip_ratio_dual # -clip_dual * A
+
+ clipped_pg_loss_higher = torch.max(pg_loss, pg_loss2) # clip if pg_loss < pg_loss2
+ metrics["pg_clipfrac_higher"] = (pg_loss < pg_loss2).float()
+ clipped_pg_loss_lower = torch.min(clipped_pg_loss_higher, pg_loss3) # clip if pg_loss > pg_loss3 and adv < 0
+ final_pg_loss = torch.where(advantages < 0, clipped_pg_loss_lower, clipped_pg_loss_higher)
+ metrics["pg_clipfrac_lower"] = (clipped_pg_loss_higher > pg_loss3).float() * (advantages < 0).float()
+
+ final_pg_loss = average_loss(final_pg_loss, response_mask, mode=loss_avg_mode)
+ metrics = {k: VF.masked_mean(v, response_mask).detach().item() for k, v in metrics.items()}
+ return final_pg_loss, metrics
+
+
+def compute_value_loss(
+ vpreds: torch.Tensor,
+ returns: torch.Tensor,
+ values: torch.Tensor,
+ response_mask: torch.Tensor,
+ cliprange_value: float,
+ loss_avg_mode: Literal["token", "seq"],
+) -> tuple[torch.Tensor, dict[str, float]]:
+ """Compute the value loss.
+
+ Adapted from https://github.com/huggingface/trl/blob/v0.15.0/trl/trainer/ppo_trainer.py#L556
+
+ Args:
+ vpreds (`torch.FloatTensor`):
+ Predicted values of the value head, shape (`batch_size`, `response_length`)
+ returns: (`torch.FloatTensor`):
+ Ground truth returns, shape (`batch_size`, `response_length`)
+ values (`torch.FloatTensor`):
+ Old values of value head, shape (`batch_size`, `response_length`)
+ response_mask: `(torch.Tensor)`
+ shape: (bs, response_length)
+ cliprange_value: (float)
+ The clip range for value net used in PPO. See https://arxiv.org/abs/1707.06347
+ loss_avg_mode: (Literal["token", "seq"])
+ "token": average the loss in the whole batch
+ "seq": average the loss in each sequence then average the mean of the means
+
+ Returns:
+ vf_loss: a scalar (`torch.FloatTensor`):
+ value function loss
+ vf_clipfrac: a float
+ The ratio of vf being clipped
+ vpred_mean: a float
+ The mean of predicted values
+
+ """
+ vpredclipped = torch.clamp(vpreds, values - cliprange_value, values + cliprange_value)
+ vf_loss1 = torch.square(vpreds - returns)
+ vf_loss2 = torch.square(vpredclipped - returns)
+ clipped_vf_losses = torch.max(vf_loss1, vf_loss2) # clip if vf_loss1 < vf_loss2
+ vf_loss = 0.5 * average_loss(clipped_vf_losses, response_mask, mode=loss_avg_mode)
+ metrics = {
+ "vf_clipfrac": VF.masked_mean((vf_loss1 < vf_loss2).float(), response_mask).detach().item(),
+ "vpred_mean": VF.masked_mean(vpreds, response_mask).detach().item(),
+ }
+ return vf_loss, metrics
+
+
+def compute_kl(
+ log_probs: torch.FloatTensor,
+ ref_log_probs: torch.FloatTensor,
+ kl_penalty: Literal["kl", "abs", "mse", "low_var_kl", "full"],
+) -> torch.Tensor:
+ """Compute KL divergence given log_probs and ref_log_probs.
+
+ Adapted from https://github.com/huggingface/trl/blob/v0.11.0/trl/trainer/ppo_trainer.py#L1150
+
+ Args:
+ log_probs: torch.Tensor
+ ref_log_probs: torch.Tensor
+ kl_penalty: str ("kl", "abs", "mse", "low_var_kl", "full")
+
+ Returns:
+ kl_div: torch.Tensor
+
+ """
+ log_probs, ref_log_probs = log_probs.float(), ref_log_probs.float()
+ if kl_penalty == "kl":
+ return log_probs - ref_log_probs
+
+ if kl_penalty == "abs":
+ return (log_probs - ref_log_probs).abs()
+
+ if kl_penalty == "mse":
+ return 0.5 * (log_probs - ref_log_probs).square()
+
+ # J. Schulman. Approximating kl divergence, 2020.
+ # URL http://joschu.net/blog/kl-approx.html
+ if kl_penalty == "low_var_kl":
+ # For numerical stability
+ kl = (ref_log_probs - log_probs).clamp(-20.0, 20.0)
+ kld = (kl.exp() - kl - 1).contiguous()
+ return torch.clamp(kld, min=-10.0, max=10.0)
+
+ if kl_penalty == "full":
+ return F.kl_div(ref_log_probs, log_probs, log_target=True, reduction="none").sum(-1)
+
+ raise NotImplementedError(f"Unknown KL penalty: {kl_penalty}.")
diff --git a/code/verl/trainer/data_loader.py b/code/verl/trainer/data_loader.py
new file mode 100644
index 0000000000000000000000000000000000000000..c5dfdb97e209f1ab798322edb911740c219fe38c
--- /dev/null
+++ b/code/verl/trainer/data_loader.py
@@ -0,0 +1,157 @@
+# Copyright 2024 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 typing import Optional
+
+import torch
+from torch.utils.data import RandomSampler, SequentialSampler
+from torchdata.stateful_dataloader import StatefulDataLoader
+from transformers import AutoConfig, PreTrainedTokenizer, ProcessorMixin
+
+from ..utils.dataset import RLHFDataset, TaskGroupedBatchSampler, collate_fn
+from .config import DataConfig
+
+
+def create_dataloader(
+ config: DataConfig,
+ tokenizer: PreTrainedTokenizer,
+ processor: Optional[ProcessorMixin],
+ model_path: Optional[str] = None,
+) -> None:
+ # Auto-detect model_type from model config for proper position_ids routing
+ model_type = None
+ if model_path is not None:
+ try:
+ auto_config = AutoConfig.from_pretrained(model_path, trust_remote_code=True)
+ model_type = getattr(auto_config, "model_type", None)
+ except Exception:
+ pass
+
+ train_dataset = RLHFDataset(
+ data_path=config.train_files,
+ tokenizer=tokenizer,
+ processor=processor,
+ prompt_key=config.prompt_key,
+ answer_key=config.answer_key,
+ image_key=config.image_key,
+ video_key=config.video_key,
+ image_dir=config.image_dir,
+ video_fps=config.video_fps,
+ video_max_frames=config.video_max_frames,
+ max_prompt_length=config.max_prompt_length,
+ truncation="right",
+ format_prompt=config.format_prompt,
+ image_min_pixels=config.image_min_pixels,
+ image_max_pixels=config.image_max_pixels,
+ video_min_pixels=config.video_min_pixels,
+ video_max_pixels=config.video_max_pixels,
+ video_total_pixels=config.video_total_pixels,
+ filter_overlong_prompts=config.filter_overlong_prompts,
+ filter_overlong_prompts_workers=config.filter_overlong_prompts_workers,
+ use_preprocessed_videos=config.use_preprocessed_videos,
+ video_source_mode=config.video_source_mode,
+ preprocessed_video_dir=config.preprocessed_video_dir,
+ inline_video_tensors=config.inline_video_tensors,
+ enable_thinking=config.enable_thinking,
+ response_prefix=config.response_prefix,
+ model_type=model_type,
+ )
+ if config.mini_rollout_batch_size is not None:
+ train_batch_size = config.mini_rollout_batch_size
+ else:
+ train_batch_size = config.rollout_batch_size
+
+ if config.group_by_task:
+ batch_sampler = TaskGroupedBatchSampler(
+ dataset=train_dataset,
+ batch_size=train_batch_size,
+ task_key=config.group_by_task_key,
+ shuffle=config.shuffle,
+ seed=config.seed,
+ drop_last=True,
+ )
+ train_dataloader = StatefulDataLoader(
+ dataset=train_dataset,
+ batch_sampler=batch_sampler,
+ num_workers=config.dataloader_num_workers,
+ collate_fn=collate_fn,
+ pin_memory=False,
+ )
+ else:
+ if config.shuffle:
+ train_dataloader_generator = torch.Generator()
+ train_dataloader_generator.manual_seed(config.seed)
+ sampler = RandomSampler(data_source=train_dataset, generator=train_dataloader_generator)
+ else:
+ sampler = SequentialSampler(data_source=train_dataset)
+
+ train_dataloader = StatefulDataLoader(
+ dataset=train_dataset,
+ batch_size=train_batch_size,
+ sampler=sampler,
+ num_workers=config.dataloader_num_workers,
+ collate_fn=collate_fn,
+ pin_memory=False,
+ drop_last=True,
+ )
+
+ val_dataset = RLHFDataset(
+ data_path=config.val_files,
+ tokenizer=tokenizer,
+ processor=processor,
+ prompt_key=config.prompt_key,
+ answer_key=config.answer_key,
+ image_key=config.image_key,
+ video_key=config.video_key,
+ image_dir=config.image_dir,
+ video_fps=config.val_video_fps,
+ video_max_frames=config.val_video_max_frames,
+ max_prompt_length=config.max_prompt_length,
+ truncation="right",
+ format_prompt=config.format_prompt,
+ image_min_pixels=config.image_min_pixels,
+ image_max_pixels=config.image_max_pixels,
+ video_min_pixels=config.val_video_min_pixels,
+ video_max_pixels=config.val_video_max_pixels,
+ video_total_pixels=config.val_video_total_pixels,
+ filter_overlong_prompts=config.filter_overlong_prompts,
+ use_preprocessed_videos=config.use_preprocessed_videos,
+ video_source_mode=config.val_video_source_mode,
+ preprocessed_video_dir=config.val_preprocessed_video_dir,
+ inline_video_tensors=config.inline_video_tensors,
+ enable_thinking=config.enable_thinking,
+ response_prefix=config.response_prefix,
+ model_type=model_type,
+ )
+
+ if config.val_batch_size == -1:
+ val_batch_size = len(val_dataset)
+ else:
+ val_batch_size = config.val_batch_size
+
+ val_dataloader = StatefulDataLoader(
+ dataset=val_dataset,
+ batch_size=val_batch_size,
+ shuffle=False,
+ num_workers=config.dataloader_num_workers,
+ collate_fn=collate_fn,
+ pin_memory=False,
+ drop_last=False,
+ )
+
+ assert len(train_dataloader) >= 1
+ assert len(val_dataloader) >= 1
+ print(f"Size of train dataloader: {len(train_dataloader)}")
+ print(f"Size of val dataloader: {len(val_dataloader)}")
+ return train_dataloader, val_dataloader
diff --git a/code/verl/trainer/main.py b/code/verl/trainer/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d0e042af504397b95c76ac924772f8e40ea875d
--- /dev/null
+++ b/code/verl/trainer/main.py
@@ -0,0 +1,137 @@
+# Copyright 2024 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.
+
+import json
+import os
+
+import ray
+from omegaconf import OmegaConf
+
+from ..single_controller.ray import RayWorkerGroup
+from ..utils.tokenizer import get_processor, get_tokenizer
+from ..workers.fsdp_workers import FSDPWorker
+from ..workers.reward import AutoRewardManager
+from .config import PPOConfig
+from .data_loader import create_dataloader
+from .ray_trainer import RayPPOTrainer, ResourcePoolManager, Role
+
+
+# please make sure main_task is not scheduled on head
+@ray.remote(num_cpus=1)
+class Runner:
+ """A runner for RL training."""
+
+ def run(self, config: PPOConfig):
+ # print config
+ print(json.dumps(config.to_dict(), indent=2))
+
+ # instantiate tokenizer
+ tokenizer_path = config.worker.actor.model.tokenizer_path
+ tokenizer = get_tokenizer(
+ tokenizer_path,
+ override_chat_template=config.data.override_chat_template,
+ trust_remote_code=config.worker.actor.model.trust_remote_code,
+ use_fast=True,
+ )
+ processor = get_processor(
+ tokenizer_path,
+ override_chat_template=config.data.override_chat_template,
+ trust_remote_code=config.worker.actor.model.trust_remote_code,
+ use_fast=True,
+ )
+
+ # define worker classes
+ ray_worker_group_cls = RayWorkerGroup
+ role_worker_mapping = {
+ Role.ActorRolloutRef: ray.remote(FSDPWorker),
+ Role.Critic: ray.remote(FSDPWorker),
+ }
+ global_pool_id = "global_pool"
+ resource_pool_spec = {
+ global_pool_id: [config.trainer.n_gpus_per_node] * config.trainer.nnodes,
+ }
+ mapping = {
+ Role.ActorRolloutRef: global_pool_id,
+ Role.Critic: global_pool_id,
+ }
+ resource_pool_manager = ResourcePoolManager(resource_pool_spec=resource_pool_spec, mapping=mapping)
+
+ RemoteRewardManager = ray.remote(AutoRewardManager).options(num_cpus=config.worker.reward.num_cpus)
+ reward_fn = RemoteRewardManager.remote(config.worker.reward, tokenizer)
+ val_reward_fn = RemoteRewardManager.remote(config.worker.reward, tokenizer)
+
+ train_dataloader, val_dataloader = create_dataloader(
+ config.data, tokenizer, processor,
+ model_path=config.worker.actor.model.model_path,
+ )
+
+ trainer = RayPPOTrainer(
+ config=config,
+ tokenizer=tokenizer,
+ processor=processor,
+ train_dataloader=train_dataloader,
+ val_dataloader=val_dataloader,
+ role_worker_mapping=role_worker_mapping,
+ resource_pool_manager=resource_pool_manager,
+ ray_worker_group_cls=ray_worker_group_cls,
+ reward_fn=reward_fn,
+ val_reward_fn=val_reward_fn,
+ )
+ trainer.init_workers()
+ trainer.fit()
+
+
+def main():
+ cli_args = OmegaConf.from_cli()
+ default_config = OmegaConf.structured(PPOConfig())
+
+ if hasattr(cli_args, "config"):
+ config_path = cli_args.pop("config", None)
+ file_config = OmegaConf.load(config_path)
+ default_config = OmegaConf.merge(default_config, file_config)
+
+ ppo_config = OmegaConf.merge(default_config, cli_args)
+ ppo_config: PPOConfig = OmegaConf.to_object(ppo_config)
+ ppo_config.deep_post_init()
+
+ if not ray.is_initialized():
+ runtime_env = {
+ "env_vars": {
+ "TOKENIZERS_PARALLELISM": "true",
+ "NCCL_DEBUG": "WARN",
+ "VLLM_LOGGING_LEVEL": "WARN",
+ "TORCH_NCCL_AVOID_RECORD_STREAMS": "1",
+ "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:False",
+ "CUDA_DEVICE_MAX_CONNECTIONS": "1",
+ "VLLM_ALLREDUCE_USE_SYMM_MEM": "0",
+ }
+ }
+ # Forward diagnostic/guard toggles (VERL_*) from the driver to every Ray
+ # worker so the no-cache video token/feature audit + guard behave
+ # consistently across nodes (e.g. VERL_DIAGNOSE_VIDEO_MISMATCH_LOG).
+ for _key, _val in os.environ.items():
+ if _key.startswith("VERL_"):
+ runtime_env["env_vars"].setdefault(_key, _val)
+ ray.init(runtime_env=runtime_env)
+
+ runner = Runner.remote()
+ ray.get(runner.run.remote(ppo_config))
+
+ if ppo_config.trainer.ray_timeline is not None:
+ # use `export RAY_PROFILING=1` to record the ray timeline
+ ray.timeline(filename=ppo_config.trainer.ray_timeline)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/verl/trainer/metrics.py b/code/verl/trainer/metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..74edaa425d400b29515dafe0c1a49b24ef6a8b6d
--- /dev/null
+++ b/code/verl/trainer/metrics.py
@@ -0,0 +1,132 @@
+# Copyright 2024 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 typing import Any
+
+import numpy as np
+import torch
+
+from ..protocol import DataProto
+
+
+def reduce_metrics(metrics: dict[str, list[Any]]) -> dict[str, Any]:
+ return {key: np.mean(value) for key, value in metrics.items()}
+
+
+def compute_length_metrics(batch: DataProto) -> dict[str, Any]:
+ max_response_length = batch.batch["responses"].size(-1)
+ max_prompt_length = batch.batch["attention_mask"].size(-1) - max_response_length
+
+ prompt_length = batch.batch["attention_mask"][:, :-max_response_length].sum(-1).float()
+ response_length = batch.batch["attention_mask"][:, -max_response_length:].sum(-1).float()
+
+ return {
+ # response length
+ "response_length/mean": torch.mean(response_length).detach().item(),
+ "response_length/max": torch.max(response_length).detach().item(),
+ "response_length/min": torch.min(response_length).detach().item(),
+ "response_length/clip_ratio": torch.eq(response_length, max_response_length).float().mean().detach().item(),
+ # prompt length
+ "prompt_length/mean": torch.mean(prompt_length).detach().item(),
+ "prompt_length/max": torch.max(prompt_length).detach().item(),
+ "prompt_length/min": torch.min(prompt_length).detach().item(),
+ "prompt_length/clip_ratio": torch.eq(prompt_length, max_prompt_length).float().mean().detach().item(),
+ }
+
+
+def compute_data_metrics(batch: DataProto, use_critic: bool = False) -> dict[str, Any]:
+ sequence_score = batch.batch["token_level_scores"].sum(-1)
+ sequence_reward = batch.batch["token_level_rewards"].sum(-1)
+
+ advantages = batch.batch["advantages"]
+ returns = batch.batch["returns"]
+
+ max_response_length = batch.batch["responses"].size(-1)
+ response_mask = batch.batch["attention_mask"][:, -max_response_length:].bool()
+
+ valid_adv = torch.masked_select(advantages, response_mask)
+ valid_returns = torch.masked_select(returns, response_mask)
+
+ if use_critic:
+ values = batch.batch["values"]
+ valid_values = torch.masked_select(values, response_mask)
+ return_diff_var = torch.var(valid_returns - valid_values)
+ return_var = torch.var(valid_returns)
+
+ return {
+ # score
+ "critic/score/mean": torch.mean(sequence_score).detach().item(),
+ "critic/score/std": torch.std(sequence_score, unbiased=False).detach().item(),
+ "critic/score/var": torch.var(sequence_score, unbiased=False).detach().item(),
+ "critic/score/max": torch.max(sequence_score).detach().item(),
+ "critic/score/min": torch.min(sequence_score).detach().item(),
+ # reward
+ "critic/rewards/mean": torch.mean(sequence_reward).detach().item(),
+ "critic/rewards/std": torch.std(sequence_reward, unbiased=False).detach().item(),
+ "critic/rewards/max": torch.max(sequence_reward).detach().item(),
+ "critic/rewards/min": torch.min(sequence_reward).detach().item(),
+ # adv
+ "critic/advantages/mean": torch.mean(valid_adv).detach().item(),
+ "critic/advantages/std": torch.std(valid_adv, unbiased=False).detach().item(),
+ "critic/advantages/max": torch.max(valid_adv).detach().item(),
+ "critic/advantages/min": torch.min(valid_adv).detach().item(),
+ # returns
+ "critic/returns/mean": torch.mean(valid_returns).detach().item(),
+ "critic/returns/max": torch.max(valid_returns).detach().item(),
+ "critic/returns/min": torch.min(valid_returns).detach().item(),
+ **(
+ {
+ # values
+ "critic/values/mean": torch.mean(valid_values).detach().item(),
+ "critic/values/max": torch.max(valid_values).detach().item(),
+ "critic/values/min": torch.min(valid_values).detach().item(),
+ # vf explained var
+ "critic/vf_explained_var": (1.0 - return_diff_var / (return_var + 1e-5)).detach().item(),
+ }
+ if use_critic
+ else {}
+ ),
+ **compute_length_metrics(batch),
+ }
+
+
+def compute_timing_metrics(batch: DataProto, timing_raw: dict[str, float]) -> dict[str, Any]:
+ num_response_tokens = torch.sum(batch.batch["response_mask"]).item()
+ # compute num_overall_tokens: use global_token_num if available, otherwise compute from attention_mask
+ if "global_token_num" in batch.meta_info:
+ num_overall_tokens = sum(batch.meta_info["global_token_num"])
+ else:
+ num_overall_tokens = torch.sum(batch.batch["attention_mask"]).item()
+ num_tokens_of_section = {
+ **dict.fromkeys(["gen", "reward"], num_response_tokens),
+ **dict.fromkeys(["rollout_generate_part", "reward_compute_part"], num_response_tokens),
+ **dict.fromkeys(["ref", "old", "values", "adv", "update_critic", "update_actor"], num_overall_tokens),
+ }
+ return {
+ **{f"timing_s/{name}": value for name, value in timing_raw.items()},
+ **{
+ f"timing_per_token_ms/{name}": timing_raw[name] * 1000 / num_tokens_of_section[name]
+ for name in set(num_tokens_of_section.keys()) & set(timing_raw.keys())
+ },
+ }
+
+
+def compute_throughout_metrics(batch: DataProto, timing_raw: dict[str, float], num_gpus: int) -> dict[str, Any]:
+ total_num_tokens = sum(batch.meta_info["global_token_num"])
+ time = timing_raw["step"]
+ return {
+ "perf/total_num_tokens": total_num_tokens,
+ "perf/time_per_step": time,
+ "perf/throughput": total_num_tokens / (time * num_gpus),
+ }
diff --git a/code/verl/trainer/orarl_config.py b/code/verl/trainer/orarl_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..9e32b33eb1e5761b9f80b742a970c45a00e8c1c9
--- /dev/null
+++ b/code/verl/trainer/orarl_config.py
@@ -0,0 +1,183 @@
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Copyright 2026 OraRL contributors
+#
+# 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.
+"""Validation and serialization for the GRPO/OraRL algorithm surface.
+
+The trainer accepts exactly two recipes. Every OraRL option is checked here,
+before any worker is created, so an inconsistent batch layout or a missing
+oracle builder fails immediately instead of half-way through the first step.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import asdict
+from typing import Any
+
+
+SUPPORTED_ALGORITHMS = frozenset({"grpo", "orarl"})
+
+_ORACLE_CONFIG_PREFIXES = (
+ "oracle_",
+ "directional_gain",
+ "detached_oracle_",
+ "selection_",
+ "post_selection_",
+)
+
+
+def algorithm_name(config: Any) -> str:
+ """Return the normalized algorithm name."""
+
+ return str(getattr(config.algorithm, "name", "grpo")).strip().lower()
+
+
+def is_orarl(config: Any) -> bool:
+ return algorithm_name(config) == "orarl"
+
+
+def validate_algorithm(config: Any) -> None:
+ """Validate GRPO/OraRL options before workers are initialized."""
+
+ name = algorithm_name(config)
+ if name not in SUPPORTED_ALGORITHMS:
+ choices = ", ".join(sorted(SUPPORTED_ALGORITHMS))
+ raise ValueError(f"algorithm.name must be one of {{{choices}}}, got {name!r}.")
+ if name == "grpo":
+ return
+
+ algo = config.algorithm
+ estimator = getattr(algo.adv_estimator, "value", algo.adv_estimator)
+ if estimator != "grpo":
+ raise ValueError("OraRL requires algorithm.adv_estimator=grpo.")
+ if bool(getattr(algo, "scale_rewards", True)):
+ raise ValueError("OraRL requires raw advantages: algorithm.scale_rewards=false.")
+ if not bool(algo.oracle_injection):
+ raise ValueError("OraRL requires algorithm.oracle_injection=true.")
+ if str(algo.oracle_injection_mode) != "append":
+ raise ValueError("OraRL requires algorithm.oracle_injection_mode=append.")
+ if not str(algo.oracle_builder or "").strip():
+ raise ValueError("OraRL requires algorithm.oracle_builder.")
+ if int(config.worker.rollout.n) <= 1:
+ raise ValueError("OraRL requires worker.rollout.n > 1.")
+ if bool(getattr(config.worker.rollout, "calculate_log_probs", False)):
+ raise ValueError(
+ "OraRL oracle rows require worker.rollout.calculate_log_probs=false so "
+ "old log probabilities are recomputed for their actual tokens."
+ )
+
+ prune_ratio = float(algo.selection_prune_ratio)
+ if not math.isfinite(prune_ratio) or not 0.0 < prune_ratio < 1.0:
+ raise ValueError("OraRL selection_prune_ratio must be in (0, 1).")
+ n_rollouts = int(config.worker.rollout.n)
+ keep_rows = max(1, int(n_rollouts * (1.0 - prune_ratio)))
+ if not bool(algo.selection_keep_oracle):
+ raise ValueError("OraRL requires the oracle rollout to be retained.")
+ oracle_slot = 1
+ positive = int(algo.selection_positive_quota)
+ negative = int(algo.selection_negative_quota)
+ if positive < 0 or negative < 0:
+ raise ValueError("OraRL selection quotas must be non-negative.")
+ if positive + negative + oracle_slot != keep_rows:
+ raise ValueError(
+ "OraRL selection quotas must fill the keep budget: "
+ f"positive({positive}) + negative({negative}) + "
+ f"oracle({oracle_slot}) != keep_rows({keep_rows})."
+ )
+ if not bool(algo.selection_strict_sign_balance):
+ raise ValueError("OraRL requires strict sign-balanced selection.")
+
+ nodes = int(config.trainer.nnodes)
+ gpus_per_node = int(config.trainer.n_gpus_per_node)
+ rollout_batch_size = int(config.data.rollout_batch_size)
+ if nodes <= 0 or gpus_per_node <= 0 or rollout_batch_size <= 0:
+ raise ValueError(
+ "OraRL requires positive trainer node/GPU counts and rollout_batch_size."
+ )
+ world_size = nodes * gpus_per_node
+ selected_batch = rollout_batch_size * keep_rows
+ if selected_batch % world_size != 0:
+ raise ValueError(
+ "OraRL selected batch must divide the actor world size: "
+ f"{selected_batch} rows for world_size={world_size}."
+ )
+ # The oracle is appended as an extra row, so the pre-selection batch is
+ # rollout_batch_size*(n+1) and seqlen-balanced partitioning needs it to
+ # shard evenly.
+ preselection_batch = rollout_batch_size * (n_rollouts + 1)
+ if preselection_batch % world_size != 0:
+ raise ValueError(
+ "OraRL append-oracle batch must divide the actor world size: "
+ f"{preselection_batch} rows for world_size={world_size}."
+ )
+
+ if not bool(algo.directional_gain):
+ raise ValueError("OraRL requires algorithm.directional_gain=true.")
+ if not bool(algo.directional_gain_positive_only):
+ raise ValueError("OraRL requires positive-only directional gain.")
+ if not bool(algo.directional_gain_recenter):
+ raise ValueError("OraRL requires post-gain policy re-centering.")
+ gain_gamma = float(algo.directional_gain_gamma)
+ if not math.isfinite(gain_gamma) or gain_gamma <= 0.0:
+ raise ValueError("OraRL directional_gain_gamma must be finite and positive.")
+ if not bool(algo.detached_oracle_advantage):
+ raise ValueError("OraRL requires a detached oracle advantage.")
+ detached_scale = float(algo.detached_oracle_advantage_scale)
+ if not math.isfinite(detached_scale) or detached_scale <= 0.0:
+ raise ValueError(
+ "OraRL detached_oracle_advantage_scale must be finite and positive."
+ )
+ gap_beta = float(algo.oracle_reward_gate_beta)
+ if not math.isfinite(gap_beta) or gap_beta <= 0.0:
+ raise ValueError("OraRL oracle_reward_gate_beta must be finite and positive.")
+ if bool(algo.detached_oracle_use_directional_gain):
+ raise ValueError(
+ "The released OraRL recipe keeps directional gain policy-only; "
+ "set detached_oracle_use_directional_gain=false."
+ )
+ match_ratio = float(algo.detached_oracle_match_best_ratio)
+ match_min = float(algo.detached_oracle_match_best_min)
+ match_max = float(algo.detached_oracle_match_best_max)
+ if (
+ not all(math.isfinite(value) for value in (match_ratio, match_min, match_max))
+ or match_ratio <= 0.0
+ or match_min < 0.0
+ or match_max < match_min
+ ):
+ raise ValueError(
+ "OraRL oracle cap requires a finite positive ratio and "
+ "finite 0 <= min <= max."
+ )
+ if not bool(algo.post_selection_recenter):
+ raise ValueError("OraRL requires post-selection re-centering.")
+ if not bool(algo.post_selection_rms_match):
+ raise ValueError("OraRL requires post-selection RMS matching.")
+ rms_min = float(algo.post_selection_rms_min_scale)
+ if not 0.0 <= rms_min <= 1.0:
+ raise ValueError("OraRL post_selection_rms_min_scale must be in [0, 1].")
+
+
+def public_config_dict(config: Any) -> dict[str, Any]:
+ """Serialize the config, dropping options the selected recipe never reads."""
+
+ value = asdict(config)
+ if algorithm_name(config) != "grpo":
+ return value
+
+ algorithm = value.get("algorithm", {})
+ for key in tuple(algorithm):
+ if key.startswith(_ORACLE_CONFIG_PREFIXES):
+ algorithm.pop(key, None)
+ value.get("worker", {}).get("actor", {}).pop("selection_prune_ratio", None)
+ return value
diff --git a/code/verl/trainer/orarl_post_selection.py b/code/verl/trainer/orarl_post_selection.py
new file mode 100644
index 0000000000000000000000000000000000000000..afc9bade91a6d7b56cb8d8e085bb3d9546520842
--- /dev/null
+++ b/code/verl/trainer/orarl_post_selection.py
@@ -0,0 +1,142 @@
+"""Pure helpers for OraRL post-selection moment correction."""
+
+import math
+from dataclasses import dataclass
+
+import torch
+
+
+SIGMA_OP_FALLBACK_THRESHOLD = 1e-3
+
+
+@dataclass(frozen=True)
+class PostSelectionReference:
+ """Pre-selection on-policy statistics for one rollout group."""
+
+ on_policy_rms: float
+ sigma_op: float
+ on_policy_rows: int = 0
+
+
+def balance_post_selection_group(
+ active_advantages: torch.Tensor,
+ is_oracle_row: torch.Tensor,
+ *,
+ reference: PostSelectionReference,
+ recenter: bool,
+ rms_match: bool,
+ rms_min_scale: float = 0.25,
+ eps: float = 1e-6,
+) -> tuple[torch.Tensor, dict[str, float]]:
+ """Recenter one selected group and optionally match its pre-selection RMS.
+
+ The active group contains the policy rows retained by OraRL selection and
+ one detached oracle row. If recentering would make the oracle advantage
+ negative, the vector is projected onto ``sum(A)=0, A_oracle>=0`` by setting
+ the oracle to zero and distributing the correction evenly over policy rows.
+ RMS matching can only downscale.
+ """
+
+ if active_advantages.ndim != 1:
+ raise ValueError(
+ "active_advantages must be one-dimensional, got "
+ f"{tuple(active_advantages.shape)}."
+ )
+ if (
+ is_oracle_row.ndim != 1
+ or is_oracle_row.shape != active_advantages.shape
+ ):
+ raise ValueError(
+ "is_oracle_row must match active_advantages, got "
+ f"{tuple(is_oracle_row.shape)} and {tuple(active_advantages.shape)}."
+ )
+ if active_advantages.numel() == 0:
+ raise ValueError("active_advantages must not be empty.")
+ if not bool(torch.isfinite(active_advantages).all()):
+ raise ValueError("active_advantages contains non-finite values.")
+ if rms_match and not recenter:
+ raise ValueError("rms_match requires recenter.")
+ if not 0.0 <= rms_min_scale <= 1.0:
+ raise ValueError(
+ f"rms_min_scale must be in [0, 1], got {rms_min_scale}."
+ )
+ if (
+ not math.isfinite(reference.on_policy_rms)
+ or not math.isfinite(reference.sigma_op)
+ or reference.on_policy_rms < 0.0
+ or reference.sigma_op < 0.0
+ ):
+ raise ValueError(
+ "reference RMS and sigma_op must be finite and non-negative."
+ )
+
+ oracle_mask = is_oracle_row.to(
+ device=active_advantages.device,
+ dtype=torch.bool,
+ )
+ oracle_rows = int(oracle_mask.sum().item())
+ if oracle_rows > 1:
+ raise ValueError(
+ "post-selection correction supports at most one oracle row, "
+ f"got {oracle_rows}."
+ )
+ policy_rows = int((~oracle_mask).sum().item())
+ if oracle_rows == 1 and policy_rows == 0:
+ raise ValueError(
+ "post-selection correction requires at least one active policy row "
+ "alongside the oracle row."
+ )
+
+ active_before = active_advantages
+ mean_before = active_before.mean()
+ rms_before = torch.sqrt(torch.mean(active_before.square()))
+ active_after = active_before.clone()
+ oracle_projection = active_before.new_zeros(())
+
+ if recenter:
+ active_after -= mean_before
+ if (
+ oracle_rows == 1
+ and float(active_after[oracle_mask].item()) < 0.0
+ ):
+ policy_mask = ~oracle_mask
+ correction = -active_after[oracle_mask]
+ active_after[oracle_mask] = 0.0
+ active_after[policy_mask] -= correction / float(policy_rows)
+ oracle_projection = active_before.new_ones(())
+
+ rms_scale = active_before.new_ones(())
+ sigma_fallback = reference.sigma_op < SIGMA_OP_FALLBACK_THRESHOLD
+ if rms_match:
+ active_rms = torch.sqrt(torch.mean(active_after.square()))
+ if float(active_rms.item()) > eps and not sigma_fallback:
+ target_rms = active_after.new_tensor(reference.on_policy_rms)
+ rms_scale = torch.clamp(
+ target_rms / (active_rms + eps),
+ min=float(rms_min_scale),
+ max=1.0,
+ )
+ active_after *= rms_scale
+
+ mean_after = active_after.mean()
+ rms_after = torch.sqrt(torch.mean(active_after.square()))
+ oracle_after = (
+ float(active_after[oracle_mask].item())
+ if oracle_rows == 1
+ else 0.0
+ )
+ metrics = {
+ "active_mean_before": float(mean_before.item()),
+ "active_rms_before": float(rms_before.item()),
+ "on_policy_rms": float(reference.on_policy_rms),
+ "rms_scale": float(rms_scale.item()),
+ "oracle_sign_projection": float(oracle_projection.item()),
+ "sigma_op_fallback": float(sigma_fallback),
+ "active_mean_after": float(mean_after.item()),
+ "active_rms_after": float(rms_after.item()),
+ "oracle_advantage_after": oracle_after,
+ "active_rows": float(active_after.numel()),
+ "active_policy_rows": float((~oracle_mask).sum().item()),
+ "oracle_rows": float(oracle_rows),
+ }
+ return active_after, metrics
diff --git a/code/verl/trainer/orarl_selection.py b/code/verl/trainer/orarl_selection.py
new file mode 100644
index 0000000000000000000000000000000000000000..0722d2c9fc7ecb9e2ad34557c9ff75b38cbc7cd2
--- /dev/null
+++ b/code/verl/trainer/orarl_selection.py
@@ -0,0 +1,227 @@
+"""OraRL's strict sign-balanced rollout selection."""
+
+from collections import defaultdict
+from typing import Any
+
+import numpy as np
+import torch
+
+from ..protocol import DataProto
+from ..utils import torch_functional as VF
+
+
+def _sequence_advantages(
+ advantages: torch.Tensor,
+ response_mask: torch.Tensor,
+) -> torch.Tensor:
+ if advantages.shape != response_mask.shape:
+ raise ValueError(
+ "advantages and response_mask must have identical shapes, got "
+ f"{tuple(advantages.shape)} and {tuple(response_mask.shape)}."
+ )
+ return VF.masked_mean(advantages, response_mask, dim=-1)
+
+
+def _rank_by_magnitude(rows: list[int], scores: torch.Tensor) -> list[int]:
+ return sorted(
+ rows,
+ key=lambda row: (-abs(float(scores[row].item())), row),
+ )
+
+
+@torch.no_grad()
+def select_orarl_rollouts(
+ data: DataProto,
+ *,
+ n_rollouts: int,
+ prune_ratio: float,
+ world_size: int,
+ positive_quota: int,
+ negative_quota: int,
+ oracle_key: str = "is_oracle_row",
+) -> tuple[DataProto, dict[str, float]]:
+ """Retain one oracle row and sign-balanced policy rows in every group."""
+
+ if not 0.0 < prune_ratio < 1.0:
+ raise ValueError("OraRL prune_ratio must be in (0, 1).")
+ if n_rollouts <= 1:
+ raise ValueError("OraRL n_rollouts must be greater than one.")
+ if world_size <= 0:
+ raise ValueError("OraRL world_size must be positive.")
+ keep_per_group = max(1, int(n_rollouts * (1.0 - prune_ratio)))
+ if positive_quota < 0 or negative_quota < 0:
+ raise ValueError("OraRL sign quotas must be non-negative.")
+ if positive_quota + negative_quota + 1 != keep_per_group:
+ raise ValueError(
+ "OraRL sign quotas plus one oracle must equal the keep budget."
+ )
+
+ required_batch = ("advantages", "response_mask", "attention_mask")
+ missing_batch = sorted(
+ key for key in required_batch if key not in data.batch
+ )
+ if missing_batch:
+ raise ValueError(
+ "OraRL selection is missing batch fields: "
+ + ", ".join(missing_batch)
+ )
+ if "uid" not in data.non_tensor_batch:
+ raise ValueError("OraRL selection requires non_tensor_batch['uid'].")
+ if oracle_key not in data.non_tensor_batch:
+ raise ValueError(
+ f"OraRL selection requires non_tensor_batch[{oracle_key!r}]."
+ )
+
+ signed_scores = _sequence_advantages(
+ data.batch["advantages"],
+ data.batch["response_mask"],
+ )
+ total_rows = int(signed_scores.numel())
+ uids = np.asarray(data.non_tensor_batch["uid"], dtype=object)
+ oracle_flags = np.asarray(
+ data.non_tensor_batch[oracle_key],
+ dtype=bool,
+ )
+ if len(uids) != total_rows or len(oracle_flags) != total_rows:
+ raise ValueError(
+ "OraRL uid and oracle flags must align with batch rows."
+ )
+
+ grouped: dict[Any, list[int]] = defaultdict(list)
+ for row, uid in enumerate(uids):
+ grouped[uid].append(row)
+
+ selected_rows: list[int] = []
+ positive_kept = 0
+ negative_kept = 0
+ zero_kept = 0
+ cross_sign_fallback = 0
+ zero_fallback = 0
+
+ for uid, rows in grouped.items():
+ oracle_rows = [row for row in rows if bool(oracle_flags[row])]
+ if len(oracle_rows) != 1:
+ raise ValueError(
+ f"OraRL group {uid!r} requires exactly one oracle row, "
+ f"got {len(oracle_rows)}."
+ )
+ candidates = [row for row in rows if row != oracle_rows[0]]
+ if len(candidates) != n_rollouts:
+ raise ValueError(
+ f"OraRL group {uid!r} requires {n_rollouts} policy rows, "
+ f"got {len(candidates)}."
+ )
+
+ positive = _rank_by_magnitude(
+ [
+ row
+ for row in candidates
+ if float(signed_scores[row].item()) > 0.0
+ ],
+ signed_scores,
+ )
+ negative = _rank_by_magnitude(
+ [
+ row
+ for row in candidates
+ if float(signed_scores[row].item()) < 0.0
+ ],
+ signed_scores,
+ )
+ zeros = [
+ row
+ for row in candidates
+ if float(signed_scores[row].item()) == 0.0
+ ]
+
+ chosen = positive[:positive_quota] + negative[:negative_quota]
+ chosen_set = set(chosen)
+ remaining = keep_per_group - 1 - len(chosen)
+ if remaining > 0:
+ surplus = _rank_by_magnitude(
+ [
+ row
+ for row in positive + negative
+ if row not in chosen_set
+ ],
+ signed_scores,
+ )
+ cross_fill = surplus[:remaining]
+ chosen.extend(cross_fill)
+ chosen_set.update(cross_fill)
+ cross_sign_fallback += len(cross_fill)
+ remaining -= len(cross_fill)
+ if remaining > 0:
+ zero_fill = [
+ row for row in zeros if row not in chosen_set
+ ][:remaining]
+ chosen.extend(zero_fill)
+ chosen_set.update(zero_fill)
+ zero_fallback += len(zero_fill)
+ remaining -= len(zero_fill)
+ if remaining:
+ raise RuntimeError(
+ f"OraRL group {uid!r} could not satisfy its keep budget."
+ )
+
+ for row in chosen:
+ score = float(signed_scores[row].item())
+ positive_kept += int(score > 0.0)
+ negative_kept += int(score < 0.0)
+ zero_kept += int(score == 0.0)
+ selected_rows.extend([oracle_rows[0], *chosen])
+
+ selected_rows.sort()
+ if len(selected_rows) % world_size:
+ raise RuntimeError(
+ f"OraRL selected batch size {len(selected_rows)} is not "
+ f"divisible by world_size {world_size}."
+ )
+
+ selected = data[selected_rows]
+ selected.meta_info = dict(selected.meta_info)
+ selected.meta_info["global_token_num"] = (
+ torch.sum(selected.batch["attention_mask"], dim=-1).tolist()
+ )
+
+ selected_index = torch.tensor(
+ selected_rows,
+ dtype=torch.long,
+ device=signed_scores.device,
+ )
+ selected_set = set(selected_rows)
+ dropped_rows = [
+ row for row in range(total_rows) if row not in selected_set
+ ]
+ metrics = {
+ "orarl/selection/groups": float(len(grouped)),
+ "orarl/selection/keep_per_group": float(keep_per_group),
+ "orarl/selection/kept_rows": float(len(selected_rows)),
+ "orarl/selection/dropped_rows": float(
+ total_rows - len(selected_rows)
+ ),
+ "orarl/selection/effective_keep_ratio": (
+ len(selected_rows) / float(total_rows)
+ ),
+ "orarl/selection/oracle_rows_forced": float(len(grouped)),
+ "orarl/selection/positive_policy_rows_kept": float(positive_kept),
+ "orarl/selection/negative_policy_rows_kept": float(negative_kept),
+ "orarl/selection/zero_policy_rows_kept": float(zero_kept),
+ "orarl/selection/cross_sign_fallback_rows": float(
+ cross_sign_fallback
+ ),
+ "orarl/selection/zero_fallback_rows": float(zero_fallback),
+ "orarl/selection/abs_advantage_kept_mean": float(
+ signed_scores.index_select(0, selected_index).abs().mean().item()
+ ),
+ }
+ if dropped_rows:
+ dropped_index = torch.tensor(
+ dropped_rows,
+ dtype=torch.long,
+ device=signed_scores.device,
+ )
+ metrics["orarl/selection/abs_advantage_dropped_mean"] = float(
+ signed_scores.index_select(0, dropped_index).abs().mean().item()
+ )
+ return selected, metrics
diff --git a/code/verl/trainer/ray_trainer.py b/code/verl/trainer/ray_trainer.py
new file mode 100644
index 0000000000000000000000000000000000000000..16e576060190862afbc859c597425b5593697e45
--- /dev/null
+++ b/code/verl/trainer/ray_trainer.py
@@ -0,0 +1,2149 @@
+# Copyright 2024 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.
+"""
+PPO Trainer with Ray-based single controller.
+This trainer supports model-agonistic model initialization with huggingface.
+"""
+
+import importlib
+import json
+import math
+import os
+import time
+import uuid
+from collections import defaultdict, deque
+from copy import deepcopy
+from dataclasses import dataclass, field
+from enum import IntEnum, auto
+from typing import Any, Optional, Type
+
+import numpy as np
+import ray
+import torch
+from ray.experimental.tqdm_ray import tqdm
+from torchdata.stateful_dataloader import StatefulDataLoader
+from transformers import PreTrainedTokenizer, ProcessorMixin
+
+from ..protocol import DataProto, pad_dataproto_to_divisor, unpad_dataproto
+from ..single_controller.base import Worker
+from ..single_controller.ray import RayClassWithInitArgs, RayResourcePool, RayWorkerGroup
+from ..single_controller.ray.base import create_colocated_worker_cls
+from ..utils import torch_functional as VF
+from ..utils.checkpoint import CHECKPOINT_TRACKER, find_latest_ckpt, remove_obsolete_ckpt, thin_out_old_ckpts
+from ..utils.logger import Tracker
+from ..utils.multimodal_contract import validate_multi_modal_data_contract
+from ..utils.py_functional import convert_dict_to_str, timer, unflatten_dict
+from ..utils.seqlen_balancing import get_seqlen_balanced_partitions, log_seqlen_unbalance
+from ..workers.fsdp_workers import FSDPWorker
+from ..workers.reward import AutoRewardManager
+from .config import PPOConfig
+from .core_algos import (
+ AdvantageEstimator,
+ FixedKLController,
+ KLController,
+ compute_advantage_return,
+ compute_kl,
+ get_kl_controller,
+)
+from .orarl_post_selection import (
+ PostSelectionReference,
+ balance_post_selection_group,
+)
+from .orarl_selection import select_orarl_rollouts
+from .metrics import (
+ compute_data_metrics,
+ compute_length_metrics,
+ compute_throughout_metrics,
+ compute_timing_metrics,
+ reduce_metrics,
+)
+from .orarl_config import (
+ is_orarl,
+ validate_algorithm,
+)
+
+
+def _disable_tqdm() -> bool:
+ return os.getenv("VERL_DISABLE_TQDM", "0") == "1"
+
+
+def _print_step_summary_enabled() -> bool:
+ return os.getenv("VERL_PRINT_STEP_SUMMARY", "1") == "1"
+
+
+def _skip_old_log_probs_enabled() -> bool:
+ return os.getenv("VERL_SKIP_OLD_LOGPROBS", "0") == "1"
+
+
+class _NoOpProgress:
+ def update(self, *args, **kwargs) -> None:
+ pass
+
+
+def _fmt_metric(value: Any, precision: int = 4) -> str:
+ if value is None:
+ return "n/a"
+ try:
+ return f"{float(value):.{precision}f}"
+ except (TypeError, ValueError):
+ return str(value)
+
+
+def _fmt_duration(seconds: float) -> str:
+ seconds = max(0, int(seconds))
+ hours, remainder = divmod(seconds, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ if hours:
+ return f"{hours}h{minutes:02d}m{seconds:02d}s"
+ return f"{minutes}m{seconds:02d}s"
+
+
+class Role(IntEnum):
+ """
+ To create more roles dynamically, you can subclass Role and add new members
+ """
+
+ Actor = auto()
+ Rollout = auto()
+ ActorRollout = auto()
+ Critic = auto()
+ RefPolicy = auto()
+ RewardModel = auto()
+ ActorRolloutRef = auto()
+
+
+@dataclass
+class ResourcePoolManager:
+ """
+ Define a resource pool specification. Resource pool will be initialized first.
+ """
+
+ resource_pool_spec: dict[str, list[int]]
+ mapping: dict[Role, str]
+ resource_pool_dict: dict[str, RayResourcePool] = field(default_factory=dict)
+
+ def create_resource_pool(self):
+ """Create ray resource pools for distributed training."""
+ for resource_pool_name, process_on_nodes in self.resource_pool_spec.items():
+ # max_colocate_count means the number of WorkerGroups (i.e. processes) in each RayResourcePool
+ # For FSDP backend, we recommend using max_colocate_count=1 that merge all WorkerGroups into one.
+ # For Megatron backend, we recommend using max_colocate_count>1 that can utilize different WorkerGroup for different models
+ resource_pool = RayResourcePool(
+ process_on_nodes=process_on_nodes, use_gpu=True, max_colocate_count=1, name_prefix=resource_pool_name
+ )
+ self.resource_pool_dict[resource_pool_name] = resource_pool
+
+ self._check_resource_available()
+
+ def get_resource_pool(self, role: Role) -> RayResourcePool:
+ """Get the resource pool of the worker."""
+ return self.resource_pool_dict[self.mapping[role]]
+
+ def get_num_gpus(self) -> int:
+ """Get the number of gpus in this cluster."""
+ return sum([n_gpus for process_on_nodes in self.resource_pool_spec.values() for n_gpus in process_on_nodes])
+
+ def _check_resource_available(self):
+ """Check if the resource pool can be satisfied in this ray cluster."""
+ gpus_available = ray.available_resources().get("GPU", 0)
+ gpus_required = self.get_num_gpus()
+ if gpus_available < gpus_required:
+ raise ValueError(f"Total available GPUs {gpus_available} is less than total desired GPUs {gpus_required}.")
+
+
+def _load_dotted_callable(spec: str):
+ """Resolve a 'pkg.mod:attr' string into the callable it points to."""
+ if ":" not in spec:
+ raise ValueError(
+ f"Expected a 'module.path:function' style spec, got {spec!r}. "
+ "Example: 'orarl.rewards:build_oracle_response_from_ground_truth'."
+ )
+ module_path, attr = spec.split(":", 1)
+ module = importlib.import_module(module_path)
+ try:
+ fn = getattr(module, attr)
+ except AttributeError as err:
+ raise AttributeError(f"Module {module_path!r} has no attribute {attr!r}.") from err
+ if not callable(fn):
+ raise TypeError(f"{spec!r} resolved to a non-callable object: {type(fn).__name__}")
+ return fn
+
+
+def apply_kl_penalty(data: DataProto, kl_ctrl: KLController, kl_penalty="kl"):
+ """Apply KL penalty to the token-level rewards."""
+ token_level_scores = data.batch["token_level_scores"]
+ batch_size = data.batch.batch_size[0]
+ response_mask = data.batch["response_mask"]
+
+ # compute kl between ref_policy and current policy
+ kld = compute_kl(data.batch["old_log_probs"], data.batch["ref_log_probs"], kl_penalty=kl_penalty)
+ kld = kld * response_mask # (batch_size, response_length)
+
+ data.batch["token_level_rewards"] = token_level_scores - kl_ctrl.kl_coef * kld
+
+ current_kl = torch.mean(VF.masked_mean(kld, mask=response_mask, dim=-1)).item()
+ metrics = {"actor/kl_penalty": current_kl, "actor/kl_coef": kl_ctrl.kl_coef}
+
+ # According to https://github.com/huggingface/trl/blob/v0.11.0/trl/trainer/ppo_trainer.py#L880
+ kl_ctrl.update(current_kl=current_kl, n_steps=batch_size)
+ return data, metrics
+
+
+def compute_advantage(
+ data: DataProto,
+ adv_estimator: AdvantageEstimator,
+ gamma: float = 1.0,
+ lam: float = 1.0,
+ scale_rewards: bool = True,
+ directional_gain: bool = False,
+ directional_gain_gamma: float = 0.25,
+ directional_gain_positive_only: bool = False,
+ directional_gain_recenter: bool = False,
+):
+ """Compute advantage estimates for policy optimization.
+
+ ``scale_rewards=False`` selects raw-centered GRPO advantages ``r-mean``
+ without per-group standard-deviation whitening.
+
+ When ``is_oracle_row`` exists in ``data.non_tensor_batch`` the GRPO-family
+ estimator keeps the oracle out of the baseline: the per-group mean is built
+ over on-policy rollouts only, so a perfect-reward oracle cannot suppress a
+ genuine on-policy winner. The standard deviation is also on-policy only, so
+ the oracle cannot inflate it and shrink on-policy gradients. Has no effect
+ for non-GRPO estimators or for runs without oracle rows.
+ """
+ adv_inputs = {
+ "token_level_rewards": data.batch["token_level_rewards"],
+ "response_mask": data.batch["response_mask"],
+ "index": data.non_tensor_batch["uid"],
+ "gamma": gamma,
+ "lam": lam,
+ "scale_rewards": bool(scale_rewards),
+ }
+ if "values" in data.batch:
+ adv_inputs["values"] = data.batch["values"]
+
+ if "reward_baselines" in data.batch:
+ adv_inputs["reward_baselines"] = data.batch["reward_baselines"]
+
+ telemetry: dict[str, Any] = {}
+ is_oracle_row = data.non_tensor_batch.get("is_oracle_row", None)
+ if is_oracle_row is not None:
+ adv_inputs["is_oracle_row"] = is_oracle_row
+ adv_inputs["oracle_excluded_baseline"] = True
+ if directional_gain:
+ adv_inputs["directional_gain"] = True
+ adv_inputs["directional_gain_gamma"] = float(directional_gain_gamma)
+ adv_inputs["directional_gain_positive_only"] = bool(
+ directional_gain_positive_only
+ )
+ adv_inputs["directional_gain_recenter"] = bool(directional_gain_recenter)
+ adv_inputs["_telemetry_out"] = telemetry
+
+ advantages, returns = compute_advantage_return(adv_estimator, **adv_inputs)
+ data.batch["advantages"] = advantages
+ data.batch["returns"] = returns
+ if telemetry:
+ # Stash so the training loop can merge into per-step metrics.
+ data.meta_info = dict(data.meta_info)
+ data.meta_info["_advantage_telemetry"] = telemetry
+ return data
+
+
+@torch.no_grad()
+def compute_oracle_advantage_diagnostics(
+ data: DataProto,
+ eps: float = 1e-6,
+) -> dict[str, float]:
+ """Per-step diagnostics for the oracle-excluded advantage baseline.
+
+ Returns ``orarl/*`` metrics quantifying how much the oracle row would
+ contaminate a vanilla GRPO baseline and how much the oracle-excluded
+ statistics recover. No-op without oracle rows (returns {}).
+
+ Metrics emitted (per-batch averages over groups unless noted):
+
+ Per-group statistics (reward space):
+ - orarl/mu_op_mean mean of μ_op (on-policy mean) across groups
+ - orarl/mu_g_mean mean of μ_g (full-group mean)
+ - orarl/sigma_op_mean mean of σ_op
+ - orarl/sigma_g_mean mean of σ_g
+ - orarl/sigma_ratio_mean mean of σ_g / σ_op (>1 ↔ oracle far outside
+ the on-policy reward distribution)
+ - orarl/oracle_mean_contamination
+ |μ_g - μ_op| / σ_op — how far the oracle would
+ shift the baseline; high values flag the groups
+ where excluding it matters most
+
+ Recovery counts, computed against the current ``data.batch['advantages']``:
+ - orarl/sign_flip_count on-policy rows whose advantage sign differs
+ from what vanilla GRPO (μ_g, σ_g) would give —
+ the rows the oracle-excluded baseline rescued
+ - orarl/sign_flip_rate sign_flip_count / total_op_rows
+ - orarl/groups_with_no_op_pos_baseline
+ groups where vanilla GRPO would leave every
+ on-policy advantage ≤ 0, so a sign-balanced
+ selector would have no positive policy row
+
+ Magnitudes (post-transform advantages):
+ - orarl/oracle_adv_mag_mean / oracle_adv_mag_max |A_oracle|
+ - orarl/op_adv_pos_mean mean |A_op| over on-policy positives
+ - orarl/op_adv_neg_mean mean |A_op| over on-policy negatives
+
+ Use these to confirm (a) |A_oracle| stays moderate (e.g. < 5) so PPO
+ clipping does not eat the oracle signal, and (b) sign_flip_rate is
+ non-trivial, which means the oracle-excluded baseline is doing real work.
+ """
+ is_oracle_row_np = data.non_tensor_batch.get("is_oracle_row", None)
+ if is_oracle_row_np is None:
+ return {}
+
+ response_mask = data.batch["response_mask"]
+ token_level_rewards = data.batch["token_level_rewards"]
+ advantages = data.batch["advantages"]
+
+ scores = (token_level_rewards * response_mask).sum(dim=-1) # (bsz,)
+ mask_sum = response_mask.sum(dim=-1).clamp(min=1)
+ adv_signed = (advantages * response_mask).sum(dim=-1) / mask_sum # (bsz,)
+
+ bsz = scores.shape[0]
+ is_oracle = torch.tensor(
+ [bool(is_oracle_row_np[i]) for i in range(bsz)],
+ dtype=torch.bool, device=scores.device,
+ )
+
+ uids = data.non_tensor_batch["uid"]
+ uid_to_idx: dict[Any, list[int]] = defaultdict(list)
+ for i, uid in enumerate(uids):
+ uid_to_idx[uid].append(i)
+
+ mu_op_acc, mu_g_acc = [], []
+ sigma_op_acc, sigma_g_acc = [], []
+ # Robust ratio aggregation: collect ratios ONLY from groups with non-trivial
+ # sigma_op (above SIGMA_FLOOR), so a degenerate group with σ_op≈0 doesn't
+ # blow up the per-batch average. Also report the unweighted mean(σ_g) /
+ # mean(σ_op) as a stable companion.
+ SIGMA_FLOOR = 1e-3
+ contam_acc, sigma_ratio_acc = [], []
+ degenerate_op_groups = 0
+ sign_flip = 0
+ total_op = 0
+ no_op_pos_groups = 0
+ n_groups = 0
+ oracle_mags, op_pos_mags, op_neg_mags = [], [], []
+
+ for indices in uid_to_idx.values():
+ if len(indices) < 2:
+ continue
+ idx_t = torch.tensor(indices, dtype=torch.long, device=scores.device)
+ g_scores = scores.index_select(0, idx_t)
+ g_adv = adv_signed.index_select(0, idx_t)
+ g_oracle_mask = is_oracle.index_select(0, idx_t)
+ op_mask = ~g_oracle_mask
+
+ if op_mask.sum() < 1:
+ continue
+
+ op_scores = g_scores[op_mask]
+ oracle_scores = g_scores[g_oracle_mask]
+
+ mu_op = op_scores.mean()
+ mu_g = g_scores.mean()
+ sigma_op = (op_scores.std(unbiased=False)
+ if op_scores.numel() > 1 else torch.zeros_like(mu_op))
+ sigma_g = g_scores.std(unbiased=False)
+
+ mu_op_acc.append(mu_op.item())
+ mu_g_acc.append(mu_g.item())
+ sigma_op_v = float(sigma_op.item())
+ sigma_g_v = float(sigma_g.item())
+ sigma_op_acc.append(sigma_op_v)
+ sigma_g_acc.append(sigma_g_v)
+ if sigma_op_v < SIGMA_FLOOR:
+ # σ_op too tiny → ratio/contamination divergent; skip from
+ # per-group ratio averages but still count it.
+ degenerate_op_groups += 1
+ else:
+ contam_acc.append(abs(float(mu_g.item()) - float(mu_op.item())) / sigma_op_v)
+ sigma_ratio_acc.append(sigma_g_v / sigma_op_v)
+
+ # Hypothetical baseline advantages (always full-group μ, σ) for the
+ # on-policy rows — used to count sign-flips vs the actual variant.
+ baseline_op_adv = (op_scores - mu_g) / (sigma_g + eps)
+ actual_op_adv = g_adv[op_mask]
+
+ sign_flips_mask = (
+ ((actual_op_adv > 0) & (baseline_op_adv < 0))
+ | ((actual_op_adv < 0) & (baseline_op_adv > 0))
+ )
+ sign_flip += int(sign_flips_mask.sum().item())
+ total_op += int(op_mask.sum().item())
+
+ if (baseline_op_adv <= 0).all():
+ no_op_pos_groups += 1
+
+ op_abs = actual_op_adv.abs()
+ op_pos_mags.extend(op_abs[actual_op_adv > 0].tolist())
+ op_neg_mags.extend(op_abs[actual_op_adv < 0].tolist())
+
+ if g_oracle_mask.any():
+ oracle_mags.extend(g_adv[g_oracle_mask].abs().tolist())
+
+ n_groups += 1
+
+ if n_groups == 0:
+ return {}
+
+ mean_sigma_op = sum(sigma_op_acc) / n_groups if sigma_op_acc else 0.0
+ mean_sigma_g = sum(sigma_g_acc) / n_groups if sigma_g_acc else 0.0
+ mean_mu_op = sum(mu_op_acc) / n_groups if mu_op_acc else 0.0
+ mean_mu_g = sum(mu_g_acc) / n_groups if mu_g_acc else 0.0
+
+ out: dict[str, float] = {
+ "orarl/mu_op_mean": mean_mu_op,
+ "orarl/mu_g_mean": mean_mu_g,
+ "orarl/sigma_op_mean": mean_sigma_op,
+ "orarl/sigma_g_mean": mean_sigma_g,
+ # Two ratio summaries — different aggregation:
+ # - *_per_group: average of per-group ratios (more sensitive but
+ # requires non-degenerate σ_op; we already filter < SIGMA_FLOOR).
+ # - *_of_means: ratio of per-batch averages (always finite, more
+ # stable for swanlab plots; use this if per_group is noisy).
+ "orarl/sigma_ratio_per_group": (
+ sum(sigma_ratio_acc) / max(len(sigma_ratio_acc), 1)
+ if sigma_ratio_acc else 0.0
+ ),
+ "orarl/sigma_ratio_of_means": (
+ mean_sigma_g / mean_sigma_op if mean_sigma_op > 0 else 0.0
+ ),
+ "orarl/oracle_mean_contamination_per_group": (
+ sum(contam_acc) / max(len(contam_acc), 1) if contam_acc else 0.0
+ ),
+ "orarl/oracle_mean_contamination_of_means": (
+ abs(mean_mu_g - mean_mu_op) / mean_sigma_op if mean_sigma_op > 0 else 0.0
+ ),
+ "orarl/degenerate_op_groups": float(degenerate_op_groups),
+ "orarl/sign_flip_count": float(sign_flip),
+ "orarl/sign_flip_rate": sign_flip / max(total_op, 1),
+ "orarl/groups_with_no_op_pos_baseline": float(no_op_pos_groups),
+ "orarl/n_groups": float(n_groups),
+ }
+ if oracle_mags:
+ out["orarl/oracle_adv_mag_mean"] = sum(oracle_mags) / len(oracle_mags)
+ out["orarl/oracle_adv_mag_max"] = max(oracle_mags)
+ if op_pos_mags:
+ out["orarl/op_adv_pos_mean"] = sum(op_pos_mags) / len(op_pos_mags)
+ out["orarl/op_adv_pos_count"] = float(len(op_pos_mags))
+ if op_neg_mags:
+ out["orarl/op_adv_neg_mean"] = sum(op_neg_mags) / len(op_neg_mags)
+ out["orarl/op_adv_neg_count"] = float(len(op_neg_mags))
+
+ return out
+
+
+@torch.no_grad()
+def apply_detached_oracle_advantage(
+ data: DataProto,
+ scale: float = 1.0,
+ beta: float = 1.0,
+ directional_gain_gamma: float = 0.0,
+ directional_gain_max: float = 4.0,
+ match_best_ratio: float = 0.0,
+ match_best_min: float = 0.5,
+ match_best_max: float = 2.0,
+ eps: float = 1e-6,
+) -> dict[str, float]:
+ """Overwrite each oracle row's advantage with a detached positive anchor.
+
+ A_oracle_raw = scale * w_g * gain_g
+ w_g = clip((r_oracle - mean_op) / (r_oracle + eps), 0, 1) ** beta
+ gain_g = clip((σ_g / σ_op) ** directional_gain_gamma, ≤ directional_gain_max)
+ = 1 (γ=0)
+
+ Optional best-on-policy matching (``match_best_ratio > 0``) keeps the
+ detached oracle row from dominating the contrastive policy rows:
+
+ cap_g = clip(match_best_ratio * max(A_op positive), match_best_min, match_best_max)
+ A_oracle = min(A_oracle_raw, cap_g)
+
+ The cap comes from the already-normalized, post-gain on-policy advantages in
+ the same group. When a group has no positive on-policy advantage,
+ ``match_best_min`` acts as a small bootstrap anchor rather than suppressing
+ the oracle to zero.
+
+ ``r_oracle`` is the oracle row's reward and ``mean_op`` the group's on-policy
+ mean reward (both summed ``token_level_scores``). Setting rather than scaling
+ the advantage means the oracle never reaches the policy rows through the
+ whitening denominator: combined with the on-policy-only σ, a high-reward
+ oracle can no longer inflate σ and shrink on-policy gradients.
+
+ The anchor stays positive with a magnitude that tracks how far on-policy is
+ from the oracle along two axes:
+ * reward gap ``w_g`` — →0 on solved groups, →1 on hard groups;
+ * variance gap ``gain_g`` — the same oracle-gap factor ``(σ_g/σ_op)^γ``
+ that scales the on-policy toward-oracle push. A positive
+ ``directional_gain_gamma`` puts the anchor on that same pre-cap scale.
+ σ_g is the full-group std, σ_op the on-policy std; gain_g falls back to
+ 1 on a degenerate σ_op group.
+
+ Mutates ``data.batch['advantages']`` in place, oracle rows only. No-op
+ without ``is_oracle_row``. Emits per-step and per-task telemetry.
+ """
+ SIGMA_OP_FALLBACK_THRESHOLD: float = 1e-3
+ is_oracle_row_np = data.non_tensor_batch.get("is_oracle_row", None)
+ if is_oracle_row_np is None:
+ return {}
+
+ # Prefer the pure reward (pre-KL) for gating; fall back to the KL-adjusted one.
+ if "token_level_scores" in data.batch:
+ reward_tok = data.batch["token_level_scores"]
+ else:
+ reward_tok = data.batch["token_level_rewards"]
+ advantages = data.batch["advantages"]
+ response_mask = data.batch["response_mask"]
+
+ scores = (reward_tok * response_mask).sum(dim=-1) # (bsz,)
+ bsz = scores.shape[0]
+ device = scores.device
+ is_oracle = torch.tensor(
+ [bool(is_oracle_row_np[i]) for i in range(bsz)], dtype=torch.bool, device=device
+ )
+
+ uids = data.non_tensor_batch["uid"]
+ problem_types = data.non_tensor_batch.get("problem_type", None)
+ uid_to_idx: dict[Any, list[int]] = defaultdict(list)
+ for i, uid in enumerate(uids):
+ uid_to_idx[uid].append(i)
+
+ w_acc: list[float] = []
+ a_oracle_acc: list[float] = []
+ gain_acc: list[float] = []
+ raw_a_oracle_acc: list[float] = []
+ best_op_adv_acc: list[float] = []
+ cap_acc: list[float] = []
+ cap_applied = 0
+ per_task_w: dict[str, list[float]] = defaultdict(list)
+ n_oracle_groups = 0
+ use_gain = directional_gain_gamma > 0.0
+ use_best_match = match_best_ratio > 0.0
+
+ for indices in uid_to_idx.values():
+ idx_t = torch.tensor(indices, dtype=torch.long, device=device)
+ g_oracle = is_oracle.index_select(0, idx_t)
+ if not bool(g_oracle.any()):
+ continue
+ op_mask = ~g_oracle
+ if int(op_mask.sum().item()) < 1:
+ continue
+ g_scores = scores.index_select(0, idx_t)
+ r_oracle = float(g_scores[g_oracle].max().item())
+ op_scores = g_scores[op_mask]
+ mean_op = float(op_scores.mean().item())
+ w = (r_oracle - mean_op) / (r_oracle + eps)
+ w = max(0.0, min(1.0, w))
+ if beta != 1.0:
+ w = float(w ** beta)
+ # Directional gain (σ_g/σ_op)^γ — the SAME factor that scales the
+ # on-policy toward-oracle push, so the anchor rides on the same scale.
+ gain = 1.0
+ if use_gain and int(op_mask.sum().item()) >= 2:
+ sigma_op = float(op_scores.std().item())
+ sigma_g = float(g_scores.std().item()) if g_scores.numel() >= 2 else 0.0
+ if sigma_op >= SIGMA_OP_FALLBACK_THRESHOLD:
+ gain = max(
+ 1.0,
+ min(
+ (sigma_g / (sigma_op + eps)) ** directional_gain_gamma,
+ directional_gain_max,
+ ),
+ )
+ raw_a_oracle = float(scale) * w * gain
+ a_oracle = raw_a_oracle
+ if use_best_match:
+ group_advantages = advantages.index_select(0, idx_t)
+ group_response_mask = response_mask.index_select(0, idx_t)
+ op_advantages = group_advantages[op_mask]
+ op_response_mask = group_response_mask[op_mask].to(group_advantages.dtype)
+ op_lengths = op_response_mask.sum(dim=-1).clamp_min(1.0)
+ op_sequence_advantages = (op_advantages * op_response_mask).sum(dim=-1) / op_lengths
+ positive_advantages = op_sequence_advantages[op_sequence_advantages > 0]
+ best_op_adv = (
+ float(positive_advantages.max().item())
+ if positive_advantages.numel() > 0
+ else 0.0
+ )
+ target_cap = (
+ float(match_best_ratio) * best_op_adv
+ if best_op_adv > 0.0
+ else float(match_best_min)
+ )
+ target_cap = max(float(match_best_min), min(float(match_best_max), target_cap))
+ a_oracle = min(raw_a_oracle, target_cap)
+ best_op_adv_acc.append(best_op_adv)
+ cap_acc.append(target_cap)
+ cap_applied += int(a_oracle < raw_a_oracle)
+ for j in indices:
+ if bool(is_oracle[j]):
+ advantages[j] = a_oracle * response_mask[j].to(advantages.dtype)
+ w_acc.append(w)
+ a_oracle_acc.append(a_oracle)
+ raw_a_oracle_acc.append(raw_a_oracle)
+ gain_acc.append(gain)
+ n_oracle_groups += 1
+ if problem_types is not None:
+ per_task_w[str(problem_types[indices[0]])].append(w)
+
+ if n_oracle_groups == 0:
+ return {}
+
+ data.batch["advantages"] = advantages
+
+ out: dict[str, float] = {
+ "orarl/detached_w_mean": float(sum(w_acc) / len(w_acc)),
+ "orarl/detached_a_oracle_mean": float(sum(a_oracle_acc) / len(a_oracle_acc)),
+ "orarl/detached_a_oracle_raw_mean": float(sum(raw_a_oracle_acc) / len(raw_a_oracle_acc)),
+ "orarl/detached_gain_mean": float(sum(gain_acc) / len(gain_acc)),
+ "orarl/detached_groups": float(n_oracle_groups),
+ }
+ if use_best_match:
+ out.update({
+ "orarl/detached_best_op_adv_mean": float(
+ sum(best_op_adv_acc) / len(best_op_adv_acc)
+ ),
+ "orarl/detached_match_cap_mean": float(sum(cap_acc) / len(cap_acc)),
+ "orarl/detached_match_cap_applied_frac": float(cap_applied) / float(n_oracle_groups),
+ })
+ for task, ws in per_task_w.items():
+ if ws:
+ out[f"orarl/detached_w_mean_by_task/{task}"] = float(sum(ws) / len(ws))
+ return out
+
+
+# Per-row scalar metric keys to mirror from reward_metrics into
+# `batch.non_tensor_batch` so downstream consumers can read them at the rollout
+# level. ``iou_raw`` is the un-shaped IoU, the only reward component strictly
+# aligned with the evaluation metric.
+_ROW_LEVEL_REWARD_KEYS_TO_PROPAGATE: tuple[str, ...] = ("iou_raw",)
+
+
+def _propagate_per_row_reward_metrics(
+ batch: DataProto,
+ reward_metrics: dict[str, list[float]],
+ keys: tuple[str, ...] = _ROW_LEVEL_REWARD_KEYS_TO_PROPAGATE,
+) -> None:
+ """Write selected per-row reward components into ``batch.non_tensor_batch``.
+
+ ``reward_metrics`` is a dict-of-lists in row order (length == bsz). For
+ keys present in ``reward_metrics`` we materialize an ndarray on the
+ batch's non_tensor_batch so subsequent batch slicing and selection
+ naturally keep the per-row alignment. Idempotent (skips keys already
+ present).
+ """
+ bsz = len(batch)
+ for key in keys:
+ if key in batch.non_tensor_batch:
+ continue
+ values = reward_metrics.get(key)
+ if values is None:
+ continue
+ if len(values) != bsz:
+ continue
+ arr = np.asarray(values, dtype=np.float32)
+ # Replace NaN/Inf with 0 so downstream torch ops never explode.
+ np.nan_to_num(arr, copy=False, nan=0.0, posinf=0.0, neginf=0.0)
+ batch.non_tensor_batch[key] = arr
+
+
+def _sequence_advantage_scores(
+ advantages: torch.Tensor,
+ response_mask: torch.Tensor,
+) -> torch.Tensor:
+ """Collapse token advantages without making selection response-length biased.
+
+ GRPO broadcasts one scalar advantage over every valid response token. A
+ masked sum therefore multiplies that scalar by the response length and can
+ rank a weaker, longer completion above a stronger, shorter completion.
+ Masked mean recovers the original scalar and remains well-defined for
+ estimators whose token advantages are not uniform.
+ """
+ if advantages.shape != response_mask.shape:
+ raise ValueError(
+ "advantages and response_mask must have identical shapes, got "
+ f"{tuple(advantages.shape)} and {tuple(response_mask.shape)}."
+ )
+ return VF.masked_mean(advantages, response_mask, dim=-1)
+
+
+@torch.no_grad()
+def build_orarl_post_selection_references(
+ data: DataProto,
+) -> dict[Any, PostSelectionReference]:
+ """Snapshot all policy rows before OraRL selection drops any of them."""
+ required_batch_keys = ("advantages", "response_mask")
+ missing = [key for key in required_batch_keys if key not in data.batch]
+ if missing or "uid" not in data.non_tensor_batch:
+ raise ValueError(
+ "post-selection balance requires advantages, response_mask, and uid; "
+ f"missing={missing + ([] if 'uid' in data.non_tensor_batch else ['uid'])}."
+ )
+
+ if "token_level_scores" in data.batch:
+ reward_tokens = data.batch["token_level_scores"]
+ elif "token_level_rewards" in data.batch:
+ reward_tokens = data.batch["token_level_rewards"]
+ else:
+ raise ValueError(
+ "post-selection balance requires token_level_scores or token_level_rewards."
+ )
+
+ advantages = data.batch["advantages"]
+ response_mask = data.batch["response_mask"]
+ sequence_advantages = _sequence_advantage_scores(
+ advantages,
+ response_mask,
+ ).detach().float()
+ sequence_rewards = (
+ reward_tokens * response_mask.to(reward_tokens.dtype)
+ ).sum(dim=-1).detach().float()
+ uids = np.asarray(data.non_tensor_batch["uid"], dtype=object)
+ is_oracle = np.asarray(
+ data.non_tensor_batch.get(
+ "is_oracle_row",
+ np.zeros(sequence_advantages.shape[0], dtype=bool),
+ ),
+ dtype=bool,
+ )
+ if len(uids) != sequence_advantages.shape[0] or len(is_oracle) != len(uids):
+ raise ValueError("uid and oracle flags must align with the batch rows.")
+
+ uid_to_rows: dict[Any, list[int]] = defaultdict(list)
+ for row, uid in enumerate(uids):
+ uid_to_rows[uid].append(row)
+
+ references: dict[Any, PostSelectionReference] = {}
+ device = sequence_advantages.device
+ for uid, rows in uid_to_rows.items():
+ op_rows = [row for row in rows if not bool(is_oracle[row])]
+ if not op_rows:
+ continue
+ op_idx = torch.tensor(op_rows, dtype=torch.long, device=device)
+ op_advantages = sequence_advantages.index_select(0, op_idx)
+ op_rewards = sequence_rewards.index_select(0, op_idx)
+ on_policy_rms = float(torch.sqrt(torch.mean(op_advantages.square())).item())
+ sigma_op = (
+ float(op_rewards.std().item())
+ if op_rewards.numel() >= 2
+ else 0.0
+ )
+ references[uid] = PostSelectionReference(
+ on_policy_rms=on_policy_rms,
+ sigma_op=sigma_op,
+ on_policy_rows=len(op_rows),
+ )
+ return references
+
+
+@torch.no_grad()
+def apply_orarl_post_selection_advantage_balance(
+ data: DataProto,
+ references: dict[Any, PostSelectionReference],
+ *,
+ recenter: bool,
+ rms_match: bool,
+ rms_min_scale: float,
+) -> dict[str, float]:
+ """Correct each OraRL-selected group before the actor backward pass."""
+ if not recenter and not rms_match:
+ return {}
+
+ is_oracle_np = data.non_tensor_batch.get("is_oracle_row")
+ if is_oracle_np is None:
+ return {
+ "orarl/post_selection_enabled": 1.0,
+ "orarl/post_selection_recenter": float(recenter),
+ "orarl/post_selection_rms_match": float(rms_match),
+ "orarl/post_selection_groups": 0.0,
+ "orarl/post_selection_groups_skipped": 0.0,
+ }
+
+ advantages = data.batch["advantages"]
+ response_mask = data.batch["response_mask"]
+ sequence_advantages = _sequence_advantage_scores(
+ advantages,
+ response_mask,
+ ).detach().float()
+ uids = np.asarray(data.non_tensor_batch["uid"], dtype=object)
+ is_oracle = np.asarray(is_oracle_np, dtype=bool)
+ if len(uids) != sequence_advantages.shape[0] or len(is_oracle) != len(uids):
+ raise ValueError("uid and oracle flags must align with selected batch rows.")
+
+ uid_to_rows: dict[Any, list[int]] = defaultdict(list)
+ for row, uid in enumerate(uids):
+ uid_to_rows[uid].append(row)
+
+ metric_values: dict[str, list[float]] = defaultdict(list)
+ processed_groups = 0
+ skipped_groups = 0
+ device = sequence_advantages.device
+ for uid, rows in uid_to_rows.items():
+ reference = references.get(uid)
+ group_oracle = np.asarray(
+ [bool(is_oracle[row]) for row in rows],
+ dtype=bool,
+ )
+ oracle_rows = int(group_oracle.sum())
+ if oracle_rows > 1:
+ raise ValueError(
+ f"post-selection group {uid!r} contains {oracle_rows} oracle "
+ "rows; expected at most one."
+ )
+ no_actual_selection = (
+ reference is not None
+ and reference.on_policy_rows > 0
+ and len(rows) >= reference.on_policy_rows + 1
+ )
+ if reference is None or oracle_rows == 0 or no_actual_selection:
+ skipped_groups += 1
+ continue
+
+ idx_t = torch.tensor(rows, dtype=torch.long, device=device)
+ active_advantages = sequence_advantages.index_select(0, idx_t)
+ active_is_oracle = torch.tensor(
+ group_oracle,
+ dtype=torch.bool,
+ device=device,
+ )
+ balanced, group_metrics = balance_post_selection_group(
+ active_advantages,
+ active_is_oracle,
+ reference=reference,
+ recenter=recenter,
+ rms_match=rms_match,
+ rms_min_scale=rms_min_scale,
+ )
+
+ balanced_tokens = (
+ balanced.to(dtype=advantages.dtype).unsqueeze(-1)
+ * response_mask.index_select(0, idx_t).to(advantages.dtype)
+ )
+ advantages.index_copy_(
+ 0,
+ idx_t.to(device=advantages.device),
+ balanced_tokens.to(device=advantages.device),
+ )
+ for key, value in group_metrics.items():
+ metric_values[key].append(value)
+ processed_groups += 1
+
+ data.batch["advantages"] = advantages
+ metrics: dict[str, float] = {
+ "orarl/post_selection_enabled": 1.0,
+ "orarl/post_selection_recenter": float(recenter),
+ "orarl/post_selection_rms_match": float(rms_match),
+ "orarl/post_selection_groups": float(processed_groups),
+ "orarl/post_selection_groups_skipped": float(skipped_groups),
+ }
+ for key, values in metric_values.items():
+ if values:
+ metrics[f"orarl/post_selection_{key}"] = float(
+ sum(values) / len(values)
+ )
+ return metrics
+
+
+def _rollout_token_diversity_metrics(
+ responses: torch.Tensor,
+ response_mask: torch.Tensor,
+ n_rollouts: int,
+) -> dict[str, float]:
+ """Measure whether an ``n``-sample rollout actually explores distinct text."""
+ if responses.shape != response_mask.shape:
+ raise ValueError("responses and response_mask must have identical shapes.")
+ if n_rollouts <= 1 or responses.shape[0] % n_rollouts != 0:
+ return {}
+
+ group_unique_fractions: list[float] = []
+ all_identical = 0
+ pairwise_agreements: list[float] = []
+ length_stds: list[float] = []
+ for start in range(0, responses.shape[0], n_rollouts):
+ group_ids = responses[start : start + n_rollouts]
+ group_mask = response_mask[start : start + n_rollouts].bool()
+ sequences = [
+ tuple(group_ids[i][group_mask[i]].detach().cpu().tolist())
+ for i in range(n_rollouts)
+ ]
+ unique_count = len(set(sequences))
+ group_unique_fractions.append(unique_count / n_rollouts)
+ all_identical += int(unique_count == 1)
+
+ lengths = group_mask.sum(dim=-1).to(dtype=torch.float32)
+ length_stds.append(float(lengths.std(unbiased=False).item()))
+ for i in range(n_rollouts):
+ for j in range(i + 1, n_rollouts):
+ common = group_mask[i] & group_mask[j]
+ if common.any():
+ agreement = (group_ids[i][common] == group_ids[j][common]).float().mean()
+ pairwise_agreements.append(float(agreement.item()))
+
+ num_groups = len(group_unique_fractions)
+ return {
+ "rollout/exact_unique_fraction": float(sum(group_unique_fractions) / num_groups),
+ "rollout/all_identical_group_fraction": float(all_identical / num_groups),
+ "rollout/pairwise_token_agreement_mean": float(
+ sum(pairwise_agreements) / max(1, len(pairwise_agreements))
+ ),
+ "rollout/response_length_std_mean": float(sum(length_stds) / num_groups),
+ }
+
+
+def _on_policy_reward_diversity_metrics(data: DataProto) -> dict[str, float]:
+ """Report useful reward spread per prompt, excluding injected oracle rows."""
+ if "token_level_scores" not in data.batch or "uid" not in data.non_tensor_batch:
+ return {}
+ scores = data.batch["token_level_scores"].sum(dim=-1).detach().cpu().float().tolist()
+ uids = np.asarray(data.non_tensor_batch["uid"], dtype=object)
+ is_oracle = np.asarray(
+ data.non_tensor_batch.get("is_oracle_row", np.zeros(len(scores), dtype=bool)),
+ dtype=bool,
+ )
+ grouped: dict[Any, list[float]] = defaultdict(list)
+ for uid, score, oracle_row in zip(uids, scores, is_oracle):
+ if not oracle_row:
+ grouped[uid].append(float(score))
+
+ stds: list[float] = []
+ unique_fractions: list[float] = []
+ for group_scores in grouped.values():
+ if len(group_scores) <= 1:
+ continue
+ score_tensor = torch.tensor(group_scores, dtype=torch.float32)
+ stds.append(float(score_tensor.std(unbiased=False).item()))
+ unique_fractions.append(
+ len({round(score, 7) for score in group_scores}) / len(group_scores)
+ )
+ if not stds:
+ return {}
+ return {
+ "rollout/reward_group_std_mean": float(sum(stds) / len(stds)),
+ "rollout/zero_reward_std_group_fraction": float(
+ sum(std <= 1e-8 for std in stds) / len(stds)
+ ),
+ "rollout/reward_unique_fraction": float(
+ sum(unique_fractions) / len(unique_fractions)
+ ),
+ }
+
+
+class RayPPOTrainer:
+ """
+ Note that this trainer runs on the driver process on a single CPU/GPU node.
+ """
+
+ def __init__(
+ self,
+ config: PPOConfig,
+ tokenizer: PreTrainedTokenizer,
+ processor: Optional[ProcessorMixin],
+ train_dataloader: StatefulDataLoader,
+ val_dataloader: StatefulDataLoader,
+ role_worker_mapping: dict[Role, Type[Worker]],
+ resource_pool_manager: ResourcePoolManager,
+ ray_worker_group_cls: Type[RayWorkerGroup] = RayWorkerGroup,
+ reward_fn: Optional[AutoRewardManager] = None,
+ val_reward_fn: Optional[AutoRewardManager] = None,
+ ):
+ self.tokenizer = tokenizer
+ self.processor = processor
+ self.train_dataloader = train_dataloader
+ self.val_dataloader = val_dataloader
+ self.config = config
+ self.reward_fn = reward_fn
+ self.val_reward_fn = val_reward_fn
+ self._is_orarl = is_orarl(config)
+ validate_algorithm(config)
+
+ self.val_reward_score = 0.0
+ self.best_val_reward_score = -1.0
+ self.best_global_step = None
+
+ # Best-train ckpt tracking (separate from rolling save_limit window).
+ # See TrainerConfig.keep_best_train_ckpt for rationale. The buffer is a
+ # FIFO of recent metric values; we save when the smoothed value strictly
+ # improves and we have already passed best_train_min_step.
+ self._best_train_window: deque[float] = deque(
+ maxlen=max(1, int(config.trainer.best_train_smooth_window))
+ )
+ self._best_train_score: float = float("-inf")
+ self._best_train_step: int = 0
+
+ self.hybrid_engine = config.worker.hybrid_engine
+ self.role_worker_mapping = role_worker_mapping
+ self.resource_pool_manager = resource_pool_manager
+ self.use_reward_model = Role.RewardModel in role_worker_mapping
+ self.ray_worker_group_cls = ray_worker_group_cls
+
+ # define KL control
+ if config.algorithm.disable_kl:
+ self.use_reference_policy = False
+ self.kl_ctrl = FixedKLController(init_kl_coef=0.0)
+ print("KL is disabled, no KL metrics will be logged. Please set `kl_coef=0` to log KL metrics.")
+ else:
+ self.use_reference_policy = True
+ self.kl_ctrl = get_kl_controller(config.algorithm)
+
+ if config.algorithm.adv_estimator == AdvantageEstimator.GAE:
+ self.use_critic = True
+ else:
+ self.use_critic = False
+
+ if config.algorithm.adv_estimator not in list(AdvantageEstimator):
+ raise NotImplementedError(f"Unknown advantage estimator: {config.algorithm.adv_estimator}.")
+
+ # generate_sequences uses DP_COMPUTE_PROTO, which splits the prompt
+ # batch across every actor-rollout worker before rollout.n fan-out.
+ # Validate the dataloader's actual batch size here instead of failing
+ # after all Ray workers and rollout engines have been initialized.
+ rollout_world_size = config.trainer.n_gpus_per_node * config.trainer.nnodes
+ rollout_dispatch_batch_size = (
+ config.data.mini_rollout_batch_size
+ if config.data.mini_rollout_batch_size is not None
+ else config.data.rollout_batch_size
+ )
+ if rollout_dispatch_batch_size % rollout_world_size != 0:
+ batch_size_key = (
+ "data.mini_rollout_batch_size"
+ if config.data.mini_rollout_batch_size is not None
+ else "data.rollout_batch_size"
+ )
+ raise ValueError(
+ f"{batch_size_key}={rollout_dispatch_batch_size} must be divisible by "
+ f"the actor-rollout world size {rollout_world_size} "
+ f"(trainer.nnodes={config.trainer.nnodes} * "
+ f"trainer.n_gpus_per_node={config.trainer.n_gpus_per_node}). "
+ "The prompt batch is sharded before worker.rollout.n is applied."
+ )
+
+ if config.data.rollout_batch_size % config.worker.actor.global_batch_size != 0:
+ raise ValueError("Rollout batch size must be divisible by actor global batch size.")
+
+ if (
+ config.data.rollout_batch_size * config.worker.rollout.n
+ ) % config.worker.actor.micro_batch_size_per_device_for_experience != 0:
+ raise ValueError(
+ "Rollout batch size * rollout.n must be divisible by actor micro batch size for experience."
+ )
+
+ if self.use_critic:
+ if config.data.rollout_batch_size % config.worker.critic.global_batch_size != 0:
+ raise ValueError("Rollout batch size must be divisible by critic global batch size.")
+
+ if (
+ config.data.rollout_batch_size * config.worker.rollout.n
+ ) % config.worker.critic.micro_batch_size_per_device_for_experience != 0:
+ raise ValueError(
+ "Rollout batch size * rollout.n must be divisible by critic micro batch size for experience."
+ )
+
+ if (
+ config.algorithm.adv_estimator in (AdvantageEstimator.GRPO, AdvantageEstimator.RLOO)
+ and config.worker.rollout.n == 1
+ ):
+ raise ValueError("GRPO and RLOO algorithm need `config.worker.rollout.n > 1`.")
+
+ _scale_rewards = bool(getattr(config.algorithm, "scale_rewards", True))
+ _adv_est = getattr(
+ config.algorithm.adv_estimator,
+ "value",
+ config.algorithm.adv_estimator,
+ )
+ if _adv_est == "grpo":
+ if _scale_rewards:
+ print("[GRPO] Scaled advantages ON: A=(r-mean)/(std+eps).")
+ else:
+ print("[GRPO] Raw-centered advantages ON: A=r-mean (no std whitening).")
+
+ self._oracle_builder = None
+ if self._is_orarl:
+ if getattr(config.worker.rollout, "calculate_log_probs", False):
+ raise ValueError(
+ "OraRL oracle rows require worker.rollout.calculate_log_probs=false "
+ "so old log probabilities are recomputed for their actual tokens."
+ )
+ self._oracle_builder = _load_dotted_callable(
+ config.algorithm.oracle_builder
+ )
+ print(
+ "[OraRL] enabled | append annotation-as-rollout | "
+ f"directional_gain_gamma={config.algorithm.directional_gain_gamma} | "
+ "detached oracle | strict sign-balanced selection | "
+ "post-selection moment correction"
+ )
+
+ if config.trainer.max_steps is not None:
+ self.training_steps = config.trainer.max_steps
+ elif config.data.mini_rollout_batch_size is not None:
+ num_examples = len(train_dataloader) * config.data.mini_rollout_batch_size
+ self.training_steps = num_examples // config.data.rollout_batch_size * config.trainer.total_epochs
+ else:
+ self.training_steps = len(train_dataloader) * config.trainer.total_epochs
+
+ rollout_rows = config.data.rollout_batch_size * config.worker.rollout.n
+ actor_global_batch_size = config.worker.actor.global_batch_size
+ self.skip_old_log_probs = (
+ _skip_old_log_probs_enabled()
+ and config.worker.actor.ppo_epochs == 1
+ and actor_global_batch_size in {config.data.rollout_batch_size, rollout_rows}
+ )
+ if _skip_old_log_probs_enabled() and not self.skip_old_log_probs:
+ print(
+ "[trainer] VERL_SKIP_OLD_LOGPROBS=1 requested but disabled for this run: "
+ "requires actor.ppo_epochs=1 and one actor global mini-batch per rollout step."
+ )
+ elif self.skip_old_log_probs:
+ print(
+ "[trainer] VERL_SKIP_OLD_LOGPROBS=1: skipping FSDP old_log_probs recompute; "
+ "actor update will use its first forward log_probs.detach() as old_log_probs. "
+ "Reference-policy KL, if enabled, is still computed separately."
+ )
+
+ config.worker.actor.optim.training_steps = self.training_steps
+ config.worker.critic.optim.training_steps = self.training_steps
+ print(f"Total training steps: {self.training_steps}")
+
+ def init_workers(self) -> None:
+ """Init resource pool and worker group"""
+ self.resource_pool_manager.create_resource_pool()
+ self.resource_pool_to_cls = {pool: {} for pool in self.resource_pool_manager.resource_pool_dict.values()}
+
+ # create actor, rollout and ref
+ if self.hybrid_engine:
+ resource_pool = self.resource_pool_manager.get_resource_pool(Role.ActorRolloutRef)
+ actor_rollout_ref_cls = RayClassWithInitArgs(
+ cls=self.role_worker_mapping[Role.ActorRolloutRef], config=self.config.worker, role="actor_rollout_ref"
+ )
+ self.resource_pool_to_cls[resource_pool]["actor_rollout_ref"] = actor_rollout_ref_cls
+ else:
+ raise NotImplementedError
+
+ # create critic
+ if self.use_critic:
+ resource_pool = self.resource_pool_manager.get_resource_pool(Role.Critic)
+ critic_cls = RayClassWithInitArgs(
+ cls=self.role_worker_mapping[Role.Critic], config=self.config.worker, role="critic"
+ )
+ self.resource_pool_to_cls[resource_pool]["critic"] = critic_cls
+
+ # create a reward model if reward_fn is None
+ if self.use_reward_model:
+ # we create a RM here
+ resource_pool = self.resource_pool_manager.get_resource_pool(Role.RewardModel)
+ rm_cls = RayClassWithInitArgs(
+ cls=self.role_worker_mapping[Role.RewardModel], config=self.config.worker, role="reward"
+ )
+ self.resource_pool_to_cls[resource_pool]["rm"] = rm_cls
+
+ # initialize WorkerGroup
+ # NOTE: if you want to use a different resource pool for each role, which can support different parallel size,
+ # you should not use `create_colocated_worker_cls`. Instead, directly pass different resource pool to different worker groups.
+ # See https://github.com/volcengine/verl/blob/master/examples/ray/tutorial.ipynb for more information.
+ all_wg: dict[str, FSDPWorker] = {}
+ self.wg_dicts = []
+ for resource_pool, class_dict in self.resource_pool_to_cls.items():
+ worker_dict_cls = create_colocated_worker_cls(class_dict=class_dict)
+ wg_dict = self.ray_worker_group_cls(resource_pool=resource_pool, ray_cls_with_init=worker_dict_cls)
+ spawn_wg = wg_dict.spawn(prefix_set=class_dict.keys())
+ all_wg.update(spawn_wg)
+ # keep the referece of WorkerDict to support ray >= 2.31. Ref: https://github.com/ray-project/ray/pull/45699
+ self.wg_dicts.append(wg_dict)
+
+ if self.use_critic:
+ self.critic_wg = all_wg["critic"]
+ self.critic_wg.init_model()
+
+ if self.use_reward_model:
+ self.rm_wg = all_wg["rm"]
+ self.rm_wg.init_model()
+
+ # we should create rollout at the end so that vllm can have a better estimation of kv cache memory
+ self.actor_rollout_ref_wg = all_wg["actor_rollout_ref"]
+ self.actor_rollout_ref_wg.init_model()
+
+ def _save_checkpoint(self) -> None:
+ # path: {save_checkpoint_path}/global_step_{global_step}/{actor,critic}
+ # Only advance best_global_step on steps that actually ran validation.
+ # The condition must match the one guarding _validate() in the main loop.
+ # Otherwise val_reward_score stays at its initial 0.0 while
+ # best_val_reward_score starts at -1.0, so 0.0 > -1.0 always holds and
+ # the first save is pinned as "best" forever. remove_obsolete_ckpt would
+ # then protect the oldest checkpoint and evict recent ones instead — with
+ # save_limit=2 the disk keeps the oldest plus the newest rather than the
+ # two most recent.
+ validation_ran_this_step = (
+ self.val_reward_fn is not None
+ and self.config.trainer.val_freq > 0
+ and self.global_step % self.config.trainer.val_freq == 0
+ )
+ if validation_ran_this_step and self.val_reward_score > self.best_val_reward_score:
+ self.best_val_reward_score = self.val_reward_score
+ self.best_global_step = self.global_step
+
+ remove_obsolete_ckpt(
+ self.config.trainer.save_checkpoint_path,
+ self.global_step,
+ self.best_global_step,
+ self.config.trainer.save_limit,
+ )
+ folder_path = os.path.join(self.config.trainer.save_checkpoint_path, f"global_step_{self.global_step}")
+ actor_path = os.path.join(folder_path, "actor")
+ self.actor_rollout_ref_wg.save_checkpoint(actor_path, save_model_only=self.config.trainer.save_model_only)
+
+ if self.use_critic:
+ critic_path = os.path.join(folder_path, "critic")
+ self.critic_wg.save_checkpoint(critic_path, save_model_only=self.config.trainer.save_model_only)
+
+ dataloader_path = os.path.join(folder_path, "dataloader.pt")
+ dataloader_state_dict = self.train_dataloader.state_dict()
+ torch.save(dataloader_state_dict, dataloader_path)
+
+ checkpointer_tracker_info = {
+ "best_global_step": self.best_global_step,
+ "best_val_reward_score": round(self.best_val_reward_score, 4),
+ "last_global_step": self.global_step,
+ "last_actor_path": os.path.abspath(actor_path),
+ }
+ checkpointer_tracker_path = os.path.join(self.config.trainer.save_checkpoint_path, CHECKPOINT_TRACKER)
+ with open(checkpointer_tracker_path, "w") as f:
+ json.dump(checkpointer_tracker_info, f, ensure_ascii=False, indent=2)
+
+ # Keep optimizer/extra_state/dataloader only for the newest checkpoint and
+ # thin older ones down to weights (huggingface/ + model_*.pt).
+ if (
+ self.config.trainer.keep_optim_only_latest
+ and not self.config.trainer.save_model_only
+ ):
+ thin_out_old_ckpts(
+ self.config.trainer.save_checkpoint_path,
+ keep_full_step=self.global_step,
+ )
+
+ def _maybe_save_best_train_checkpoint(self, metrics: dict) -> None:
+ """Save model-only ckpt when smoothed train metric hits a new best.
+
+ Independent of save_freq / save_limit:
+ - Lives under {save_checkpoint_path}/best_train/global_step_{N}/actor/
+ - Always model-only (no optim/dataloader): for downstream eval only.
+ - Old best is removed atomically when a new best is found, so the
+ best_train/ subdir holds at most ONE checkpoint.
+
+ Decision rule:
+ smoothed = mean of last `best_train_smooth_window` values of
+ metrics[best_train_metric_key]
+ if step >= best_train_min_step AND smoothed > self._best_train_score:
+ save and update tracker
+ """
+ cfg = self.config.trainer
+ if not getattr(cfg, "keep_best_train_ckpt", False):
+ return
+
+ metric_val = metrics.get(cfg.best_train_metric_key)
+ if metric_val is None:
+ return
+ try:
+ metric_val = float(metric_val)
+ except (TypeError, ValueError):
+ return
+
+ self._best_train_window.append(metric_val)
+
+ if self.global_step < int(cfg.best_train_min_step):
+ return
+ if len(self._best_train_window) < self._best_train_window.maxlen:
+ # Wait until the smoothing buffer is full, otherwise early steps
+ # have an artificially small window and bias toward early peaks.
+ return
+
+ smoothed = sum(self._best_train_window) / len(self._best_train_window)
+ if smoothed <= self._best_train_score:
+ return
+
+ # New best: save model-only into a side directory.
+ prev_best_step = self._best_train_step
+ prev_best_score = self._best_train_score
+ self._best_train_score = smoothed
+ self._best_train_step = self.global_step
+
+ best_root = os.path.join(cfg.save_checkpoint_path, "best_train")
+ os.makedirs(best_root, exist_ok=True)
+ new_dir = os.path.join(best_root, f"global_step_{self.global_step}")
+ new_actor = os.path.join(new_dir, "actor")
+ # Save current step (model only). Force save_model_only=True regardless
+ # of the global flag so this side ckpt stays small.
+ self.actor_rollout_ref_wg.save_checkpoint(new_actor, save_model_only=True)
+ if self.use_critic:
+ new_critic = os.path.join(new_dir, "critic")
+ self.critic_wg.save_checkpoint(new_critic, save_model_only=True)
+
+ # Tracker file lets downstream eval scripts auto-discover the best step.
+ tracker = {
+ "best_train_step": self._best_train_step,
+ "best_train_metric_key": cfg.best_train_metric_key,
+ "best_train_smoothed_value": round(self._best_train_score, 6),
+ "best_train_smooth_window": self._best_train_window.maxlen,
+ "best_train_actor_path": os.path.abspath(new_actor),
+ "previous_best_step": prev_best_step,
+ "previous_best_smoothed_value": (
+ round(prev_best_score, 6) if prev_best_score != float("-inf") else None
+ ),
+ }
+ with open(os.path.join(best_root, "best_train_tracker.json"), "w") as f:
+ json.dump(tracker, f, ensure_ascii=False, indent=2)
+
+ # Evict the previous best ckpt directory (we only keep one in best_train/).
+ if prev_best_step and prev_best_step != self.global_step:
+ old_dir = os.path.join(best_root, f"global_step_{prev_best_step}")
+ if os.path.isdir(old_dir):
+ import shutil
+ try:
+ shutil.rmtree(old_dir)
+ except OSError as exc:
+ print(f"[best-train ckpt] WARN: failed to remove {old_dir}: {exc}")
+
+ prev_score_str = (
+ f"{prev_best_score:.4f}" if prev_best_score != float("-inf") else "n/a"
+ )
+ print(
+ f"[best-train ckpt] step={self.global_step} "
+ f"smoothed_{cfg.best_train_metric_key}={self._best_train_score:.4f} "
+ f"(prev best step={prev_best_step or 0}, value={prev_score_str}) "
+ f"→ {new_actor}",
+ flush=True,
+ )
+
+ def _load_checkpoint(self) -> None:
+ if self.config.trainer.load_checkpoint_path is not None:
+ load_checkpoint_path = self.config.trainer.load_checkpoint_path
+ elif self.config.trainer.find_last_checkpoint:
+ load_checkpoint_path, tracker_info = find_latest_ckpt(self.config.trainer.save_checkpoint_path)
+ if tracker_info is not None:
+ self.best_val_reward_score = tracker_info.get("best_val_reward_score", 0.0)
+ self.best_global_step = tracker_info.get("best_global_step", 0)
+ else:
+ load_checkpoint_path = None
+
+ if load_checkpoint_path is None:
+ return
+
+ if "global_step_" not in load_checkpoint_path.strip(os.path.sep).split(os.path.sep)[-1]:
+ raise ValueError("`load_checkpoint_path` should end with `global_step_*`.")
+
+ print(f"Load from checkpoint: {load_checkpoint_path}.")
+ self.global_step = int(load_checkpoint_path.strip(os.path.sep).split("global_step_")[-1])
+ actor_path = os.path.join(load_checkpoint_path, "actor")
+ self.actor_rollout_ref_wg.load_checkpoint(actor_path)
+ if self.use_critic:
+ critic_path = os.path.join(load_checkpoint_path, "critic")
+ self.critic_wg.load_checkpoint(critic_path)
+
+ dataloader_path = os.path.join(load_checkpoint_path, "dataloader.pt")
+ if os.path.exists(dataloader_path):
+ dataloader_state_dict = torch.load(dataloader_path, weights_only=False)
+ self.train_dataloader.load_state_dict(dataloader_state_dict)
+ else:
+ print(f"No dataloader state found at {dataloader_path}, will start from scratch.")
+
+ def _assert_multimodal_contract(self, data: DataProto, stage: str) -> None:
+ if "multi_modal_data" not in data.non_tensor_batch:
+ return
+
+ problem_ids = data.non_tensor_batch.get("problem_id", None)
+ uids = data.non_tensor_batch.get("uid", None)
+ for idx, multi_modal_data in enumerate(data.non_tensor_batch["multi_modal_data"]):
+ try:
+ validate_multi_modal_data_contract(multi_modal_data)
+ except Exception as exc:
+ problem_id = None if problem_ids is None else problem_ids[idx]
+ uid = None if uids is None else uids[idx]
+ raise ValueError(
+ f"{stage}: invalid multi_modal_data at index={idx}, uid={uid}, problem_id={problem_id}: {exc}"
+ ) from exc
+
+ def _maybe_log_val_generations(
+ self,
+ inputs: list[str],
+ outputs: list[str],
+ labels: list[str],
+ scores: list[float],
+ problem_ids: list[Any],
+ ) -> None:
+ """Log a table of validation samples"""
+ if self.config.trainer.val_generations_to_log <= 0:
+ return
+
+ # Create tuples of (input, output, label, score, problem_id) and sort by input text
+ samples = list(zip(inputs, outputs, labels, scores, problem_ids))
+ samples.sort(key=lambda x: x[0]) # Sort by input text
+
+ # Use fixed random seed for deterministic shuffling
+ rng = np.random.RandomState(42)
+ rng.shuffle(samples)
+
+ samples = samples[: self.config.trainer.val_generations_to_log]
+ self.logger.log_generation(samples, self.global_step)
+
+ def _validate(self) -> dict[str, Any]:
+ reward_tensor_lst = []
+ # Lists to collect samples for the table
+ sample_inputs, sample_outputs, sample_labels, sample_scores, sample_problem_ids = [], [], [], [], []
+ reward_metrics_lst = defaultdict(list)
+ length_metrics_lst = defaultdict(list)
+ print("Start validation...")
+ self.actor_rollout_ref_wg.prepare_rollout_engine()
+ for batch_dict in self.val_dataloader:
+ test_batch = DataProto.from_single_dict(batch_dict)
+ test_gen_batch = test_batch.pop(
+ batch_keys=["input_ids", "attention_mask", "position_ids"],
+ non_tensor_batch_keys=["raw_prompt_ids", "multi_modal_data"],
+ )
+ repeat_times = self.config.worker.rollout.val_override_config.get("n", 1)
+ test_gen_batch.meta_info = self.config.worker.rollout.val_override_config
+ test_gen_batch.meta_info["image_min_pixels"] = self.config.data.image_min_pixels
+ test_gen_batch.meta_info["image_max_pixels"] = self.config.data.image_max_pixels
+ test_gen_batch.meta_info["video_min_pixels"] = self.config.data.val_video_min_pixels
+ test_gen_batch.meta_info["video_max_pixels"] = self.config.data.val_video_max_pixels
+ test_gen_batch.meta_info["video_total_pixels"] = self.config.data.val_video_total_pixels
+ test_gen_batch.meta_info["video_fps"] = self.config.data.val_video_fps
+ test_gen_batch.meta_info["video_max_frames"] = self.config.data.val_video_max_frames
+
+ self._assert_multimodal_contract(test_gen_batch, stage="validate")
+ test_gen_batch, pad_size = pad_dataproto_to_divisor(test_gen_batch, self.actor_rollout_ref_wg.world_size)
+ test_output_gen_batch = self.actor_rollout_ref_wg.generate_sequences(test_gen_batch)
+ test_output_gen_batch = unpad_dataproto(test_output_gen_batch, pad_size=pad_size * repeat_times)
+
+ # repeat to align with repeated responses in rollout
+ test_batch = test_batch.repeat(repeat_times=repeat_times, interleave=True)
+ test_batch = test_batch.union(test_output_gen_batch)
+
+ # evaluate using reward_function
+ # Only pass fields needed by reward, excluding large multi_modal_data to reduce serialization
+ val_reward_batch = test_batch.select(
+ batch_keys=["responses", "response_mask"],
+ non_tensor_batch_keys=[k for k in test_batch.non_tensor_batch if k != "multi_modal_data"],
+ )
+ reward_tensor, reward_metrics = ray.get(self.val_reward_fn.compute_reward.remote(val_reward_batch))
+
+ # store generations
+ input_ids = test_batch.batch["prompts"]
+ input_texts = [self.tokenizer.decode(ids, skip_special_tokens=True) for ids in input_ids]
+ output_ids = test_batch.batch["responses"]
+ output_texts = [self.tokenizer.decode(ids, skip_special_tokens=True) for ids in output_ids]
+ scores = reward_tensor.sum(-1).cpu().tolist()
+ sample_inputs.extend(input_texts)
+ sample_outputs.extend(output_texts)
+ sample_labels.extend(test_batch.non_tensor_batch["ground_truth"].tolist())
+ sample_scores.extend(scores)
+ if "problem_id" in test_batch.non_tensor_batch:
+ sample_problem_ids.extend(test_batch.non_tensor_batch["problem_id"].tolist())
+ else:
+ sample_problem_ids.extend([None] * len(scores))
+
+ reward_tensor_lst.append(reward_tensor)
+ for key, value in reward_metrics.items():
+ reward_metrics_lst[key].extend(value)
+
+ for key, value in compute_length_metrics(test_batch).items():
+ length_metrics_lst[key].append(value)
+
+ self.actor_rollout_ref_wg.release_rollout_engine()
+ self._maybe_log_val_generations(
+ sample_inputs, sample_outputs, sample_labels, sample_scores, sample_problem_ids
+ )
+ if self.config.trainer.val_generations_to_log > 0 and sample_inputs:
+ print("Sample problem_id:", sample_problem_ids[0])
+ print("Sample prompt (with template):", sample_inputs[0])
+ print("Sample response:", sample_outputs[0])
+ print("Sample ground_truth:", sample_labels[0])
+ print("Sample reward:", sample_scores[0])
+ self.val_reward_score = torch.cat(reward_tensor_lst, dim=0).sum(-1).mean().item()
+ val_reward_metrics = {f"val/{key}_reward": value for key, value in reduce_metrics(reward_metrics_lst).items()}
+ val_length_metrics = {f"val_{key}": value for key, value in reduce_metrics(length_metrics_lst).items()}
+ print("Finish validation.")
+ return {"val/reward_score": self.val_reward_score, **val_reward_metrics, **val_length_metrics}
+
+ def _balance_batch(self, batch: DataProto, metrics: dict[str, Any], logging_prefix: str = "global_seqlen") -> None:
+ """Reorder the data on single controller such that each dp rank gets similar total tokens"""
+ attention_mask = batch.batch["attention_mask"]
+ batch_size = attention_mask.shape[0]
+ global_seqlen_lst = batch.batch["attention_mask"].view(batch_size, -1).sum(-1).tolist() # (train_batch_size,)
+ world_size = self.actor_rollout_ref_wg.world_size
+ global_partition_lst = get_seqlen_balanced_partitions(
+ global_seqlen_lst, k_partitions=world_size, equal_size=True
+ )
+ # reorder based on index. The data will be automatically equally partitioned by dispatch function
+ global_idx = torch.tensor([j for partition in global_partition_lst for j in partition])
+ batch.reorder(global_idx)
+ global_balance_stats = log_seqlen_unbalance(
+ seqlen_list=global_seqlen_lst, partitions=global_partition_lst, prefix=logging_prefix
+ )
+ metrics.update(global_balance_stats)
+
+ def _inject_oracle_rollout_in_gen_output(
+ self,
+ gen_batch_output: DataProto,
+ ground_truths: Optional[np.ndarray],
+ extras: Optional[np.ndarray],
+ n: int,
+ replace_index: Optional[int] = None,
+ ) -> int:
+ """Build and mark annotation-derived oracle rollout rows."""
+ if not self.config.algorithm.oracle_injection or self._oracle_builder is None:
+ return 0
+ if ground_truths is None or len(ground_truths) == 0:
+ return 0
+
+ # `replace_index` override lets callers pin the target slot regardless of
+ # the config default (used by the append path, which calls this with n=1
+ # and index 0 so every representative row becomes its group's oracle).
+ if replace_index is None:
+ replace_index = self.config.algorithm.oracle_replace_index
+ if replace_index == -1:
+ replace_index = n - 1
+
+ responses = gen_batch_output.batch["responses"].clone()
+ response_length = responses.size(1)
+ device = responses.device
+ pad_token_id = self.tokenizer.pad_token_id
+ eos_token_id = gen_batch_output.meta_info.get("eos_token_id", self.tokenizer.eos_token_id)
+ # `get_response_mask` supports list[int]; single-eos tail append needs a scalar.
+ eos_single = eos_token_id[0] if isinstance(eos_token_id, (list, tuple)) else eos_token_id
+
+ is_oracle_row = np.zeros(responses.size(0), dtype=bool)
+ replaced = 0
+ for i, oracle_raw in enumerate(ground_truths):
+ if oracle_raw is None:
+ continue
+ extra = extras[i] if extras is not None and i < len(extras) else {}
+ if extra is None:
+ extra = {}
+ try:
+ oracle_text = self._oracle_builder(str(oracle_raw), extra)
+ except Exception as err:
+ print(f"[OraRL] oracle_builder failed at idx={i} annotation={oracle_raw!r}: {err}")
+ continue
+ if not isinstance(oracle_text, str) or len(oracle_text.strip()) == 0:
+ continue
+
+ oracle_token_ids = self.tokenizer.encode(oracle_text, add_special_tokens=False)
+ # Ensure the oracle response terminates with EOS so response_mask covers it.
+ if eos_single is not None and (len(oracle_token_ids) == 0 or oracle_token_ids[-1] != eos_single):
+ oracle_token_ids = oracle_token_ids + [eos_single]
+ if len(oracle_token_ids) > response_length:
+ # Keep the tail so EOS survives; pad head is unusual, just truncate front.
+ oracle_token_ids = oracle_token_ids[-response_length:]
+
+ oracle_tokens = VF.pad_2d_list_to_length(
+ [oracle_token_ids],
+ pad_token_id,
+ max_length=response_length,
+ ).to(device)
+
+ replace_idx = i * n + replace_index
+ responses[replace_idx] = oracle_tokens.squeeze(0)
+ is_oracle_row[replace_idx] = True
+ replaced += 1
+
+ if replaced == 0:
+ return 0
+
+ prompts = gen_batch_output.batch["prompts"]
+ prompt_length = prompts.size(-1)
+ attention_mask = gen_batch_output.batch["attention_mask"]
+ position_ids = gen_batch_output.batch["position_ids"]
+
+ prompt_attention_mask = attention_mask[..., :prompt_length]
+ prompt_position_ids = position_ids[..., :prompt_length]
+
+ response_mask = VF.get_response_mask(responses, eos_token_id=eos_token_id, dtype=prompt_attention_mask.dtype)
+ sequence_ids = torch.cat([prompts, responses], dim=-1)
+
+ batch_size = responses.size(0)
+ delta_position_id = torch.arange(1, response_length + 1, device=position_ids.device)
+ delta_position_id = delta_position_id.view(1, -1).expand(batch_size, -1)
+ if prompt_position_ids.ndim == 3: # qwen2vl mrope: (batch_size, 4, seq_length)
+ delta_position_id = delta_position_id.view(batch_size, 1, -1).expand(
+ batch_size, prompt_position_ids.size(1), -1
+ )
+
+ response_position_ids = prompt_position_ids[..., -1:] + delta_position_id
+ full_position_ids = torch.cat([prompt_position_ids, response_position_ids], dim=-1)
+ full_attention_mask = torch.cat((prompt_attention_mask, response_mask), dim=-1)
+
+ gen_batch_output.batch["responses"] = responses
+ gen_batch_output.batch["input_ids"] = sequence_ids
+ gen_batch_output.batch["response_mask"] = response_mask
+ gen_batch_output.batch["position_ids"] = full_position_ids
+ gen_batch_output.batch["attention_mask"] = full_attention_mask
+ gen_batch_output.non_tensor_batch["is_oracle_row"] = is_oracle_row
+
+ # When the rollout collected per-sequence mean logprobs, the values at
+ # oracle-row positions still describe the ORIGINAL on-policy sample
+ # (different tokens), so they are meaningless for ranking. Override them
+ # with +inf so the oracle row always survives the probability pre-filter;
+ # `selection_keep_oracle` force-keeps it in the reward-based stage.
+ seq_lp = gen_batch_output.non_tensor_batch.get("seq_logprob_for_filter", None)
+ if seq_lp is not None:
+ seq_lp = np.asarray(seq_lp, dtype=np.float32, copy=True)
+ seq_lp[is_oracle_row] = np.float32(np.inf)
+ gen_batch_output.non_tensor_batch["seq_logprob_for_filter"] = seq_lp
+ return replaced
+
+ def _build_oracle_append_rows(
+ self,
+ gen_batch_output: DataProto,
+ ground_truths: np.ndarray,
+ extras: Optional[np.ndarray],
+ n: int,
+ ) -> Optional[DataProto]:
+ """Build one extra oracle rollout per prompt for append mode.
+
+ Uses the first on-policy rollout of every group as a template (it already
+ carries the correct prompt tokens + multi_modal_data), then overwrites its
+ response with the annotation-derived one by reusing
+ ``_inject_oracle_rollout_in_gen_output`` with ``n=1`` and
+ ``replace_index=0``, so every representative row becomes its group's
+ oracle.
+
+ The returned DataProto has ``len(ground_truths)`` rows (one per prompt),
+ all flagged ``is_oracle_row=True``, with the SAME batch/non-tensor keys as
+ ``gen_batch_output`` — so the caller can union it with the per-prompt
+ metadata and ``DataProto.concat`` it onto the on-policy block, growing each
+ group from ``n`` to ``n+1``. ``index_select`` copies the tensors, so
+ overwriting the template never mutates the on-policy rows.
+
+ Returns ``None`` if no oracle row could be built (empty batch or the
+ builder returned nothing for every prompt), so the caller falls back to a
+ plain GRPO group for this step.
+ """
+ num_prompts = len(ground_truths)
+ if num_prompts == 0:
+ return None
+ rep_idx = np.array([i * n for i in range(num_prompts)], dtype=np.int64)
+ oracle_rows = gen_batch_output.index_select(rep_idx)
+ replaced = self._inject_oracle_rollout_in_gen_output(
+ gen_batch_output=oracle_rows,
+ ground_truths=ground_truths,
+ extras=extras,
+ n=1,
+ replace_index=0,
+ )
+ if self._is_orarl and replaced != num_prompts:
+ raise RuntimeError(
+ "OraRL requires one valid oracle rollout per prompt, but the "
+ f"configured builder produced {replaced} of {num_prompts}."
+ )
+ if replaced == 0:
+ return None
+ return oracle_rows
+
+ def _make_batch_data(self, metrics: dict[str, Any]) -> DataProto:
+ print("Start generating batch...")
+ try:
+ batch_dict = next(self.data_iterator)
+ except StopIteration:
+ self.data_iterator = iter(self.train_dataloader)
+ batch_dict = next(self.data_iterator)
+
+ meta_info = {
+ "image_min_pixels": self.config.data.image_min_pixels,
+ "image_max_pixels": self.config.data.image_max_pixels,
+ "video_min_pixels": self.config.data.video_min_pixels,
+ "video_max_pixels": self.config.data.video_max_pixels,
+ "video_total_pixels": self.config.data.video_total_pixels,
+ "video_fps": self.config.data.video_fps,
+ "video_max_frames": self.config.data.video_max_frames,
+ }
+ new_batch: DataProto = DataProto.from_single_dict(batch_dict, meta_info=meta_info)
+ new_batch.non_tensor_batch["uid"] = np.array(
+ [str(uuid.uuid4()) for _ in range(len(new_batch.batch))], dtype=object
+ )
+
+ gen_batch = new_batch.pop(
+ batch_keys=["input_ids", "attention_mask", "position_ids"],
+ non_tensor_batch_keys=["raw_prompt_ids", "multi_modal_data"],
+ meta_info_keys=[
+ "image_min_pixels",
+ "image_max_pixels",
+ "video_min_pixels",
+ "video_max_pixels",
+ "video_total_pixels",
+ "video_fps",
+ "video_max_frames",
+ ],
+ )
+
+ self._assert_multimodal_contract(gen_batch, stage="train")
+
+ rollout_n = int(self.config.worker.rollout.n)
+ rollout_backend = str(self.config.worker.rollout.name).lower()
+ if rollout_backend in {"hf", "transformers"}:
+ if rollout_n != self.actor_rollout_ref_wg.world_size:
+ raise ValueError(
+ "Official HF rollout requires rollout.n == actor world size so "
+ "each rank contributes one completion per prompt; got "
+ f"n={rollout_n}, world_size={self.actor_rollout_ref_wg.world_size}."
+ )
+ # ONE_TO_ALL sends all unique prompts to every rank. Each rank runs
+ # one HF generation per prompt; the custom collector transposes the
+ # results into contiguous n-sized prompt groups.
+ gen_batch_output = self.actor_rollout_ref_wg.generate_sequences_hf_official(
+ gen_batch
+ )
+ # The HF workers deliberately omit this large object from their
+ # outputs. Restore the driver's original once, then repeat by n
+ # below; this avoids sending 64 duplicate 448-frame tensors back
+ # through Ray.
+ multi_modal_data = gen_batch.non_tensor_batch.get("multi_modal_data")
+ if multi_modal_data is not None:
+ new_batch.non_tensor_batch["multi_modal_data"] = multi_modal_data
+ else:
+ gen_batch_output = self.actor_rollout_ref_wg.generate_sequences(gen_batch)
+ expected_rollout_rows = len(new_batch) * rollout_n
+ if len(gen_batch_output) != expected_rollout_rows:
+ raise ValueError(
+ "Rollout output row count does not match prompt-group semantics: "
+ f"got {len(gen_batch_output)}, expected {len(new_batch)} * "
+ f"{rollout_n} = {expected_rollout_rows}."
+ )
+ metrics.update(
+ _rollout_token_diversity_metrics(
+ gen_batch_output.batch["responses"],
+ gen_batch_output.batch["response_mask"],
+ rollout_n,
+ )
+ )
+
+ if self.config.algorithm.adv_estimator == "remax":
+ gen_baseline_batch = deepcopy(gen_batch)
+ gen_baseline_batch.meta_info["temperature"] = 0
+ gen_baseline_batch.meta_info["n"] = 1
+ gen_baseline_output = self.actor_rollout_ref_wg.generate_sequences(gen_baseline_batch)
+
+ new_batch = new_batch.union(gen_baseline_output)
+ remax_reward_batch = new_batch.select(
+ batch_keys=["responses", "response_mask"],
+ non_tensor_batch_keys=[k for k in new_batch.non_tensor_batch if k != "multi_modal_data"],
+ )
+ reward_baseline_tensor, _ = ray.get(self.reward_fn.compute_reward.remote(remax_reward_batch))
+ reward_baseline_tensor = reward_baseline_tensor.sum(dim=-1)
+
+ new_batch.pop(batch_keys=list(gen_baseline_output.batch.keys()))
+ new_batch.batch["reward_baselines"] = reward_baseline_tensor
+ del gen_baseline_batch, gen_baseline_output
+
+ # Add an annotation-derived oracle response to each prompt's group.
+ # * replace mode: overwrite one on-policy rollout in place; group stays n.
+ # * append mode: keep all n on-policy rollouts and add the oracle as an
+ # extra row (group becomes n+1). We build the oracle rows here but defer
+ # the concat until the on-policy block has been assembled below.
+ n = rollout_n
+ oracle_mode = getattr(self.config.algorithm, "oracle_injection_mode", "replace")
+ append_oracle = bool(self.config.algorithm.oracle_injection and oracle_mode == "append")
+ oracle_append_rows: Optional[DataProto] = None
+ if self.config.algorithm.oracle_injection:
+ oracle_values = new_batch.non_tensor_batch.get("ground_truth", None)
+ if oracle_values is None:
+ raise KeyError(
+ "algorithm.oracle_injection=True but `ground_truth` is missing from the "
+ "batch non_tensor_batch. Check your dataset's answer_key."
+ )
+ # Oracle builders need the task family to avoid inferring it from
+ # ambiguous labels such as a single option letter. Keep lightweight
+ # row metadata, but never duplicate media tensors into this side
+ # channel.
+ oracle_extra_keys = [
+ key
+ for key in new_batch.non_tensor_batch
+ if key not in {"ground_truth", "multi_modal_data", "raw_prompt_ids"}
+ ]
+ oracle_extras = np.asarray(
+ [
+ {
+ key: new_batch.non_tensor_batch[key][row]
+ for key in oracle_extra_keys
+ }
+ for row in range(len(new_batch))
+ ],
+ dtype=object,
+ )
+ if append_oracle:
+ oracle_append_rows = self._build_oracle_append_rows(
+ gen_batch_output,
+ oracle_values,
+ oracle_extras,
+ n,
+ )
+ metrics["orarl/oracle/groups_injected"] = int(
+ 0 if oracle_append_rows is None else len(oracle_append_rows)
+ )
+ # Flag the on-policy block so `is_oracle_row` exists on every row
+ # before the concat below (concat requires matching keys).
+ gen_batch_output.non_tensor_batch["is_oracle_row"] = np.zeros(
+ len(gen_batch_output), dtype=bool
+ )
+ else:
+ oracle_injected = self._inject_oracle_rollout_in_gen_output(
+ gen_batch_output=gen_batch_output,
+ ground_truths=oracle_values,
+ extras=oracle_extras,
+ n=n,
+ )
+ metrics["orarl/oracle/groups_injected"] = int(oracle_injected)
+
+ # On-policy block: repeat the per-prompt metadata n times and attach the
+ # generated rollouts. `repeat` returns a fresh DataProto, so `new_batch`
+ # (the un-repeated per-prompt metadata) stays intact for the oracle rows.
+ op_batch = new_batch.repeat(repeat_times=n, interleave=True)
+ op_batch = op_batch.union(gen_batch_output)
+
+ if oracle_append_rows is not None:
+ # Attach per-prompt metadata (uid/ground_truth/problem_type/...) to the
+ # oracle rows so each carries its group's uid, then concat onto the
+ # block. Everything downstream (advantage, selection, diagnostics)
+ # groups by uid, so the oracle row need not be physically adjacent.
+ oracle_full = new_batch.union(oracle_append_rows)
+ batch_out = DataProto.concat([op_batch, oracle_full])
+ group_size = n + 1
+ else:
+ batch_out = op_batch
+ group_size = n
+
+ return batch_out[: self.config.data.rollout_batch_size * group_size]
+
+ def fit(self):
+ """
+ The training loop of PPO.
+ The driver process only need to call the compute functions of the worker group through RPC to construct the PPO dataflow.
+ The light-weight advantage computation is done on the driver process.
+ """
+ self.logger = Tracker(loggers=self.config.trainer.logger, config=self.config.to_dict())
+ self.global_step = 0
+ main_tqdm = _NoOpProgress() if _disable_tqdm() else tqdm(
+ range(self.training_steps),
+ desc="Running step",
+ position=0,
+ )
+ val_metrics: Optional[dict[str, Any]] = None
+
+ # load checkpoint before doing anything
+ self._load_checkpoint()
+ main_tqdm.update(self.global_step)
+
+ # perform validation before training
+ # currently, we only support validation using the reward_function.
+ if self.val_reward_fn is not None and self.config.trainer.val_before_train:
+ val_metrics = self._validate()
+ self.logger.log(data=val_metrics, step=self.global_step)
+ if self.config.trainer.val_only:
+ return
+
+ self.data_iterator = iter(self.train_dataloader)
+ train_start_time = time.time()
+ while self.global_step < self.training_steps:
+ self.global_step += 1
+
+ metrics, timing_raw = {}, {}
+ with timer("step", timing_raw):
+ # make a batch of data
+ with timer("gen", timing_raw):
+ self.actor_rollout_ref_wg.prepare_rollout_engine()
+ batch = self._make_batch_data(metrics=metrics)
+ self.actor_rollout_ref_wg.release_rollout_engine()
+
+ # balance the number of valid tokens on each dp rank.
+ # NOTE: this breaks the order of data inside the batch.
+ # Please take care when you implement group based adv computation such as GRPO and rloo
+ self._balance_batch(batch, metrics=metrics)
+
+ # compute global valid tokens
+ batch.meta_info["global_token_num"] = torch.sum(batch.batch["attention_mask"], dim=-1).tolist()
+
+ # compute reward asynchronously so it can overlap with old-log-prob compute.
+ if "token_level_scores" not in batch.batch:
+ with timer("reward", timing_raw):
+ reward_batch = batch.select(
+ batch_keys=["responses", "response_mask"],
+ non_tensor_batch_keys=[k for k in batch.non_tensor_batch if k != "multi_modal_data"],
+ )
+ reward_ref = self.reward_fn.compute_reward.remote(reward_batch)
+
+ # recompute old_log_probs
+ with timer("old", timing_raw):
+ # Actor update always needs the rollout temperature to compute response log-probs.
+ batch.meta_info["temperature"] = self.config.worker.rollout.temperature
+ if "rollout_log_probs" in batch.batch:
+ # Bypass mode: reuse the per-token logprobs vLLM already
+ # returned as old_log_probs. This skips one FSDP recompute
+ # and keeps the first PPO mini-batch ratio away from a
+ # constant 1, so clipping acts as a real trust region.
+ # vLLM applies log-softmax to logits/temperature, matching
+ # actor.compute_log_prob, so the values are interchangeable.
+ rollout_lp = batch.batch.pop("rollout_log_probs")
+ batch.batch["old_log_probs"] = rollout_lp.to(torch.float32)
+ elif self.skip_old_log_probs:
+ batch.meta_info["skip_old_log_probs"] = True
+ metrics["actor/old_log_probs_skipped"] = 1.0
+ else:
+ old_log_probs = self.actor_rollout_ref_wg.compute_log_probs(batch)
+ batch = batch.union(old_log_probs)
+
+ # compute ref_log_probs
+ if self.use_reference_policy:
+ with timer("ref", timing_raw):
+ ref_log_probs = self.actor_rollout_ref_wg.compute_ref_log_probs(batch)
+ batch = batch.union(ref_log_probs)
+
+ # compute values
+ if self.use_critic:
+ with timer("values", timing_raw):
+ values = self.critic_wg.compute_values(batch)
+ batch = batch.union(values)
+
+ with timer("adv", timing_raw):
+ if "token_level_scores" not in batch.batch:
+ # get token level scores asynchronously
+ reward_tensor, reward_metrics = ray.get(reward_ref)
+ batch.batch["token_level_scores"] = reward_tensor
+ _propagate_per_row_reward_metrics(batch, reward_metrics)
+ metrics.update(_on_policy_reward_diversity_metrics(batch))
+ reward_metrics = {f"reward/{k}": v for k, v in reduce_metrics(reward_metrics).items()}
+ metrics.update(reward_metrics)
+
+ # apply kl penalty if available
+ if not self.config.algorithm.use_kl_loss and self.use_reference_policy:
+ # apply kl penalty to reward
+ batch, kl_metrics = apply_kl_penalty(batch, self.kl_ctrl, self.config.algorithm.kl_penalty)
+ metrics.update(kl_metrics)
+ else:
+ batch.batch["token_level_rewards"] = batch.batch["token_level_scores"]
+
+ # compute advantages, executed on the driver process
+ batch = compute_advantage(
+ batch,
+ adv_estimator=self.config.algorithm.adv_estimator,
+ gamma=self.config.algorithm.gamma,
+ lam=self.config.algorithm.lam,
+ scale_rewards=bool(self.config.algorithm.scale_rewards),
+ directional_gain=bool(
+ self.config.algorithm.directional_gain
+ ),
+ directional_gain_gamma=float(
+ self.config.algorithm.directional_gain_gamma
+ ),
+ directional_gain_positive_only=bool(
+ self.config.algorithm.directional_gain_positive_only
+ ),
+ directional_gain_recenter=bool(
+ self.config.algorithm.directional_gain_recenter
+ ),
+ )
+ # Report the raw per-group oracle-gap statistics before any
+ # anchoring rewrites the oracle advantage. No-op without
+ # oracle rows.
+ oracle_diag = compute_oracle_advantage_diagnostics(batch)
+ if oracle_diag:
+ metrics.update(oracle_diag)
+ adv_telemetry = batch.meta_info.pop("_advantage_telemetry", None)
+ if adv_telemetry:
+ for k, v in adv_telemetry.items():
+ metrics[f"orarl/{k}"] = float(v)
+
+ # OraRL stage 4: overwrite the oracle row with a detached
+ # positive anchor so it never enters the group mean/std.
+ if self.config.algorithm.detached_oracle_advantage:
+ # The released recipe keeps the directional gain
+ # policy-only, so the anchor is governed solely by its
+ # adaptive cap.
+ _det_gain_gamma = (
+ float(self.config.algorithm.directional_gain_gamma)
+ if (
+ self.config.algorithm.directional_gain
+ and self.config.algorithm.detached_oracle_use_directional_gain
+ )
+ else 0.0
+ )
+ det_metrics = apply_detached_oracle_advantage(
+ batch,
+ scale=float(
+ self.config.algorithm.detached_oracle_advantage_scale
+ ),
+ beta=float(self.config.algorithm.oracle_reward_gate_beta),
+ directional_gain_gamma=_det_gain_gamma,
+ match_best_ratio=float(
+ self.config.algorithm.detached_oracle_match_best_ratio
+ ),
+ match_best_min=float(
+ self.config.algorithm.detached_oracle_match_best_min
+ ),
+ match_best_max=float(
+ self.config.algorithm.detached_oracle_match_best_max
+ ),
+ )
+ if det_metrics:
+ metrics.update(det_metrics)
+
+ # OraRL stage 5: retain the oracle plus a strict sign-balanced
+ # subset of policy rows before the actor backward pass.
+ if self._is_orarl and not self.use_critic:
+ full_score = batch.batch["token_level_scores"].sum(-1)
+ metrics["orarl/selection/full_batch_score_mean"] = float(
+ full_score.mean().item()
+ )
+ metrics["orarl/selection/full_batch_score_max"] = float(
+ full_score.max().item()
+ )
+ metrics["orarl/selection/full_batch_score_min"] = float(
+ full_score.min().item()
+ )
+ _post_recenter = bool(
+ self.config.algorithm.post_selection_recenter
+ )
+ _post_rms_match = bool(
+ self.config.algorithm.post_selection_rms_match
+ )
+ post_selection_references = (
+ build_orarl_post_selection_references(batch)
+ if _post_recenter or _post_rms_match
+ else {}
+ )
+ with timer("orarl_selection", timing_raw):
+ batch, selection_metrics = select_orarl_rollouts(
+ batch,
+ n_rollouts=self.config.worker.rollout.n,
+ prune_ratio=self.config.algorithm.selection_prune_ratio,
+ world_size=self.actor_rollout_ref_wg.world_size,
+ positive_quota=self.config.algorithm.selection_positive_quota,
+ negative_quota=self.config.algorithm.selection_negative_quota,
+ )
+ metrics.update(selection_metrics)
+ if _post_recenter or _post_rms_match:
+ with timer("post_selection_balance", timing_raw):
+ post_balance_metrics = (
+ apply_orarl_post_selection_advantage_balance(
+ batch,
+ post_selection_references,
+ recenter=_post_recenter,
+ rms_match=_post_rms_match,
+ rms_min_scale=float(
+ self.config.algorithm.post_selection_rms_min_scale
+ ),
+ )
+ )
+ metrics.update(post_balance_metrics)
+ # Re-balance per-rank seqlen after pruning: the original
+ # balance partitioned all G rows uniformly, but dropping the
+ # low-|adv| rows skews the per-rank totals. Cheap, and keeps
+ # the actor update from being bottlenecked on a single rank.
+ if (
+ selection_metrics.get(
+ "orarl/selection/dropped_rows",
+ 0.0,
+ )
+ > 0
+ ):
+ self._balance_batch(
+ batch,
+ metrics=metrics,
+ logging_prefix="orarl_selection_seqlen",
+ )
+
+ # update critic
+ if self.use_critic:
+ with timer("update_critic", timing_raw):
+ critic_output = self.critic_wg.update_critic(batch)
+
+ critic_metrics = reduce_metrics(critic_output.non_tensor_batch)
+ metrics.update(critic_metrics)
+
+ # update actor
+ if self.config.trainer.critic_warmup <= self.global_step:
+ metrics["actor/backward_rows"] = float(len(batch))
+ metrics["actor/backward_rows_per_prompt"] = float(
+ len(batch) / max(1, int(self.config.data.rollout_batch_size))
+ )
+ if self._is_orarl:
+ metrics["actor/oracle_injection_enabled"] = 1.0
+ metrics["actor/orarl_selection_enabled"] = 1.0
+ with timer("update_actor", timing_raw):
+ actor_output = self.actor_rollout_ref_wg.update_actor(batch)
+
+ actor_metrics = reduce_metrics(actor_output.non_tensor_batch)
+ metrics.update(actor_metrics)
+
+ # validate
+ if (
+ self.val_reward_fn is not None
+ and self.config.trainer.val_freq > 0
+ and self.global_step % self.config.trainer.val_freq == 0
+ ):
+ with timer("validation", timing_raw):
+ val_metrics = self._validate()
+
+ metrics.update(val_metrics)
+
+ if self.config.trainer.save_freq > 0 and self.global_step % self.config.trainer.save_freq == 0:
+ with timer("save_checkpoint", timing_raw):
+ self._save_checkpoint()
+
+ # collect metrics
+ num_gpus = self.resource_pool_manager.get_num_gpus()
+ metrics.update(compute_data_metrics(batch=batch, use_critic=self.use_critic))
+ metrics.update(compute_timing_metrics(batch=batch, timing_raw=timing_raw))
+ metrics.update(compute_throughout_metrics(batch=batch, timing_raw=timing_raw, num_gpus=num_gpus))
+
+ # Best-train ckpt: track every step (decoupled from save_freq) and
+ # save model-only into a side dir when smoothed reward improves.
+ with timer("save_best_train_checkpoint", timing_raw):
+ self._maybe_save_best_train_checkpoint(metrics)
+
+ if _print_step_summary_enabled():
+ step_time = metrics.get("timing_s/step")
+ elapsed = time.time() - train_start_time
+ avg_step_time = elapsed / max(1, self.global_step)
+ remaining_steps = max(0, self.training_steps - self.global_step)
+ eta = avg_step_time * remaining_steps
+ print(
+ "[TRAIN STEP] "
+ f"{self.global_step}/{self.training_steps} "
+ f"step_s={_fmt_metric(step_time, precision=1)} "
+ f"elapsed={_fmt_duration(elapsed)} "
+ f"eta={_fmt_duration(eta)}",
+ flush=True,
+ )
+
+ self.logger.log(data=metrics, step=self.global_step)
+ main_tqdm.update()
+
+ # perform validation after training (skip entirely when val_freq <= 0)
+ if self.val_reward_fn is not None and self.config.trainer.val_freq > 0:
+ if (
+ val_metrics is None
+ or self.global_step % self.config.trainer.val_freq != 0
+ ):
+ val_metrics = self._validate()
+ self.logger.log(data=val_metrics, step=self.global_step)
+
+ print(f"Final validation metrics:\n{convert_dict_to_str(unflatten_dict(val_metrics))}")
+
+ if self.config.trainer.save_freq <= 0 or self.global_step % self.config.trainer.save_freq != 0:
+ self._save_checkpoint()
diff --git a/code/verl/utils/__init__.py b/code/verl/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ce90c5eb352d85c59105c0dc85b5f1dd576f095
--- /dev/null
+++ b/code/verl/utils/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2024 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.
diff --git a/code/verl/utils/checkpoint/__init__.py b/code/verl/utils/checkpoint/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..3836cfb3662c960a88f54a1a1667d03fd938616b
--- /dev/null
+++ b/code/verl/utils/checkpoint/__init__.py
@@ -0,0 +1,18 @@
+# Copyright 2024 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 .checkpoint_manager import CHECKPOINT_TRACKER, find_latest_ckpt, remove_obsolete_ckpt, thin_out_old_ckpts
+
+
+__all__ = ["CHECKPOINT_TRACKER", "find_latest_ckpt", "remove_obsolete_ckpt", "thin_out_old_ckpts"]
diff --git a/code/verl/utils/checkpoint/checkpoint_manager.py b/code/verl/utils/checkpoint/checkpoint_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..395ba55e63d32c164e062d06bf237e2123550526
--- /dev/null
+++ b/code/verl/utils/checkpoint/checkpoint_manager.py
@@ -0,0 +1,222 @@
+# Copyright 2024 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.
+
+import json
+import os
+import random
+import re
+import shutil
+import tempfile
+from abc import ABC, abstractmethod
+from typing import Any, Optional, Union
+
+import numpy as np
+import torch
+import torch.distributed as dist
+from filelock import FileLock
+from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
+from transformers import PreTrainedTokenizer, ProcessorMixin
+
+
+CHECKPOINT_TRACKER = "checkpoint_tracker.json"
+
+
+class BaseCheckpointManager(ABC):
+ """
+ A checkpoint manager that saves and loads
+ - model
+ - optimizer
+ - lr_scheduler
+ - extra_states
+ in a SPMD way.
+
+ We save
+ - sharded model states and optimizer states
+ - full lr_scheduler states
+ - huggingface tokenizer and config for ckpt merge
+ """
+
+ def __init__(
+ self,
+ model: FSDP,
+ optimizer: torch.optim.Optimizer,
+ lr_scheduler: torch.optim.lr_scheduler.LRScheduler,
+ processing_class: Union[PreTrainedTokenizer, ProcessorMixin],
+ ):
+ self.model = model
+ self.optimizer = optimizer
+ self.lr_scheduler = lr_scheduler
+ self.processing_class = processing_class
+
+ assert isinstance(self.model, FSDP)
+ self.rank = dist.get_rank()
+ self.world_size = dist.get_world_size()
+
+ @abstractmethod
+ def load_checkpoint(self, *args, **kwargs):
+ raise NotImplementedError
+
+ @abstractmethod
+ def save_checkpoint(self, *args, **kwargs):
+ raise NotImplementedError
+
+ @staticmethod
+ def local_mkdir(path: str) -> str:
+ if not os.path.isabs(path):
+ working_dir = os.getcwd()
+ path = os.path.join(working_dir, path)
+
+ # Using hash value of path as lock file name to avoid long file name
+ lock_filename = f"ckpt_{hash(path) & 0xFFFFFFFF:08x}.lock"
+ lock_path = os.path.join(tempfile.gettempdir(), lock_filename)
+
+ try:
+ with FileLock(lock_path, timeout=60):
+ os.makedirs(path, exist_ok=True)
+ except Exception as e:
+ print(f"Warning: Failed to acquire lock for {path}: {e}")
+ os.makedirs(path, exist_ok=True) # even if the lock is not acquired, try to create the directory
+
+ return path
+
+ @staticmethod
+ def get_rng_state() -> dict[str, Any]:
+ rng_state = {
+ "cpu": torch.get_rng_state(),
+ "cuda": torch.cuda.get_rng_state(),
+ "numpy": np.random.get_state(),
+ "random": random.getstate(),
+ }
+ return rng_state
+
+ @staticmethod
+ def load_rng_state(rng_state: dict[str, Any]):
+ torch.set_rng_state(rng_state["cpu"])
+ torch.cuda.set_rng_state(rng_state["cuda"])
+ np.random.set_state(rng_state["numpy"])
+ random.setstate(rng_state["random"])
+
+
+def get_checkpoint_tracker_filename(root_path: str) -> str:
+ """
+ Tracker file rescords the latest chckpoint during training to restart from.
+ """
+ return os.path.join(root_path, CHECKPOINT_TRACKER)
+
+
+def find_latest_ckpt(
+ path: str, directory_format: str = "global_step_{}"
+) -> tuple[Optional[str], Optional[dict[str, Any]]]:
+ """
+ Find the latest checkpoint in the save path.
+ """
+ tracker_file = get_checkpoint_tracker_filename(path)
+ if not os.path.exists(tracker_file):
+ return None, None
+
+ with open(tracker_file, "rb") as f:
+ checkpointer_tracker_info = json.load(f)
+
+ ckpt_path = os.path.join(path, directory_format.format(checkpointer_tracker_info["last_global_step"]))
+ if not os.path.exists(ckpt_path):
+ print(f"Checkpoint does not exist: {ckpt_path}")
+ return None, None
+
+ print(f"Found latest checkpoint: {ckpt_path}, will resume from it. Turn off `find_last_checkpoint` to disable it.")
+ return ckpt_path, checkpointer_tracker_info
+
+
+def remove_obsolete_ckpt(
+ path: str, global_step: int, best_global_step: int, save_limit: int = -1, directory_format: str = "global_step_{}"
+):
+ """
+ Remove the obsolete checkpoints that exceed the save limit.
+ """
+ if save_limit <= 0 or not os.path.exists(path):
+ return
+
+ num_ckpt_to_keep = save_limit - 1 # exclude the current ckpt
+ pattern = re.escape(directory_format).replace(r"\{\}", r"(\d+)")
+ ckpt_global_steps = []
+ for folder in os.listdir(path):
+ if match := re.match(pattern, folder):
+ step = int(match.group(1))
+ if step < global_step:
+ ckpt_global_steps.append(step)
+
+ ckpt_global_steps.sort(reverse=True)
+ if best_global_step in ckpt_global_steps: # do not remove the best ckpt
+ ckpt_global_steps.remove(best_global_step)
+ num_ckpt_to_keep = max(num_ckpt_to_keep - 1, 0)
+
+ for step in ckpt_global_steps[num_ckpt_to_keep:]:
+ folder_path = os.path.join(path, directory_format.format(step))
+ try:
+ shutil.rmtree(folder_path, ignore_errors=True)
+ print(f"Removed obsolete checkpoint: {folder_path}")
+ except Exception as e:
+ print(f"Failed to remove {folder_path}: {e}")
+
+
+def thin_out_old_ckpts(
+ path: str,
+ keep_full_step: int,
+ directory_format: str = "global_step_{}",
+ subdirs: tuple = ("actor", "critic"),
+):
+ """Reduce every ``global_step_*`` except ``keep_full_step`` to weights only.
+
+ Model weights (``model_*.pt`` and ``huggingface/``) stay; optimizer and
+ extra_state shards plus the step-level ``dataloader.pt`` are deleted. Paired
+ with ``save_limit`` this keeps the N most recent checkpoints while only the
+ newest one can resume an optimizer, which cuts disk usage substantially.
+
+ Call order: ``remove_obsolete_ckpt`` to bound the count, then
+ ``save_checkpoint``, then this function to thin the older steps.
+ """
+ if not os.path.exists(path):
+ return
+
+ pattern = re.escape(directory_format).replace(r"\{\}", r"(\d+)")
+ for folder in os.listdir(path):
+ match = re.match(pattern, folder)
+ if match is None:
+ continue
+ step = int(match.group(1))
+ if step == keep_full_step:
+ continue
+
+ ckpt_root = os.path.join(path, folder)
+
+ # dataloader.pt sits at the root of the step directory.
+ dataloader_pt = os.path.join(ckpt_root, "dataloader.pt")
+ if os.path.isfile(dataloader_pt):
+ try:
+ os.remove(dataloader_pt)
+ except OSError as e:
+ print(f"Failed to remove {dataloader_pt}: {e}")
+
+ # optimizer / extra_state shards live under the actor/critic subdirectories.
+ for sub in subdirs:
+ subdir = os.path.join(ckpt_root, sub)
+ if not os.path.isdir(subdir):
+ continue
+ for fname in os.listdir(subdir):
+ if fname.startswith("optim_") or fname.startswith("extra_state_"):
+ try:
+ os.remove(os.path.join(subdir, fname))
+ except OSError as e:
+ print(f"Failed to remove {os.path.join(subdir, fname)}: {e}")
+
+ print(f"Thinned out old checkpoint (kept model weights only): {ckpt_root}")
diff --git a/code/verl/utils/checkpoint/fsdp_checkpoint_manager.py b/code/verl/utils/checkpoint/fsdp_checkpoint_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..d72740381c8284fcb1074d1d762dd8968392f79c
--- /dev/null
+++ b/code/verl/utils/checkpoint/fsdp_checkpoint_manager.py
@@ -0,0 +1,171 @@
+# Copyright 2024 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.
+
+import os
+import shutil
+from typing import Any, Optional, Union
+
+import torch
+import torch.distributed as dist
+from torch.distributed.checkpoint.state_dict import (
+ StateDictOptions,
+ get_model_state_dict,
+ get_state_dict,
+ set_state_dict,
+)
+from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
+from transformers import PreTrainedModel, PreTrainedTokenizer, ProcessorMixin
+
+from .checkpoint_manager import BaseCheckpointManager
+
+
+def _payload_nbytes(payload: Any) -> int:
+ to_local = getattr(payload, "to_local", None)
+ tensor = to_local() if callable(to_local) else payload
+ if isinstance(tensor, torch.Tensor):
+ return tensor.numel() * tensor.element_size()
+
+ if isinstance(payload, dict):
+ return sum(_payload_nbytes(value) for value in payload.values())
+
+ if isinstance(payload, (list, tuple, set)):
+ return sum(_payload_nbytes(value) for value in payload)
+
+ return 0
+
+
+class FSDPCheckpointManager(BaseCheckpointManager):
+ """
+ A checkpoint manager that saves and loads
+ - model
+ - optimizer
+ - lr_scheduler
+ - extra_states
+ in a SPMD way.
+
+ We save
+ - sharded model states and optimizer states
+ - full lr_scheduler states
+ - huggingface tokenizer and config for ckpt merge
+ """
+
+ def __init__(
+ self,
+ model: FSDP,
+ optimizer: torch.optim.Optimizer,
+ lr_scheduler: torch.optim.lr_scheduler.LRScheduler,
+ processing_class: Union[PreTrainedTokenizer, ProcessorMixin],
+ ):
+ super().__init__(model, optimizer, lr_scheduler, processing_class)
+
+ def load_checkpoint(self, path: Optional[str] = None):
+ if path is None:
+ return
+
+ # every rank download its own checkpoint
+ model_path = os.path.join(path, f"model_world_size_{self.world_size}_rank_{self.rank}.pt")
+ optim_path = os.path.join(path, f"optim_world_size_{self.world_size}_rank_{self.rank}.pt")
+ extra_path = os.path.join(path, f"extra_state_world_size_{self.world_size}_rank_{self.rank}.pt")
+ print(f"[rank-{self.rank}]: Loading model from {os.path.abspath(model_path)}.")
+ print(f"[rank-{self.rank}]: Loading optimizer from {os.path.abspath(optim_path)}.")
+ print(f"[rank-{self.rank}]: Loading extra_state from {os.path.abspath(extra_path)}.")
+ model_state_dict = torch.load(model_path, weights_only=False)
+ optim_state_dict = torch.load(optim_path, weights_only=False)
+ extra_state_dict = torch.load(extra_path, weights_only=False)
+
+ state_dict_options = StateDictOptions(cpu_offload=True)
+ set_state_dict(
+ model=self.model,
+ optimizers=self.optimizer,
+ model_state_dict=model_state_dict,
+ optim_state_dict=optim_state_dict,
+ options=state_dict_options,
+ )
+ self.lr_scheduler.load_state_dict(extra_state_dict["lr_scheduler"])
+
+ # recover random state
+ if "rng" in extra_state_dict:
+ self.load_rng_state(extra_state_dict["rng"])
+
+ def _save_shard(self, payload: Any, destination: str, label: str) -> None:
+ """Write one shard atomically so a truncated file never survives.
+
+ A full or over-quota checkpoint volume surfaces inside ``torch.save`` as
+ an opaque zip error, and the partial ``.pt`` left behind then fails again
+ at load time. Check capacity up front and publish via rename instead.
+ """
+ directory = os.path.dirname(destination) or "."
+ required = _payload_nbytes(payload)
+ if required > 0:
+ free = shutil.disk_usage(directory).free
+ if free < required:
+ raise RuntimeError(
+ f"[rank-{self.rank}]: Refusing to save {label}: {directory} has "
+ f"{free / 2**30:.2f} GiB free but this shard alone needs about "
+ f"{required / 2**30:.2f} GiB, and all {self.world_size} ranks write here. "
+ f"Free space or raise the quota on the checkpoint volume."
+ )
+
+ temporary = f"{destination}.tmp.rank{self.rank}"
+ try:
+ torch.save(payload, temporary)
+ os.replace(temporary, destination)
+ except Exception as error:
+ if os.path.exists(temporary):
+ os.remove(temporary)
+ raise RuntimeError(
+ f"[rank-{self.rank}]: Failed to write {label} to {destination} "
+ f"({required / 2**30:.2f} GiB). The checkpoint volume is most likely "
+ f"full or over quota."
+ ) from error
+
+ def save_checkpoint(self, path: str, save_model_only: bool = False):
+ path = self.local_mkdir(path)
+ dist.barrier()
+
+ # every rank will save its own model and optim shard
+ model_path = os.path.join(path, f"model_world_size_{self.world_size}_rank_{self.rank}.pt")
+ optim_path = os.path.join(path, f"optim_world_size_{self.world_size}_rank_{self.rank}.pt")
+ extra_path = os.path.join(path, f"extra_state_world_size_{self.world_size}_rank_{self.rank}.pt")
+
+ state_dict_options = StateDictOptions(cpu_offload=True)
+ if save_model_only:
+ model_state_dict = get_model_state_dict(self.model, options=state_dict_options)
+ print(f"[rank-{self.rank}]: Saving model to {os.path.abspath(model_path)}.")
+ self._save_shard(model_state_dict, model_path, "model")
+ else:
+ model_state_dict, optim_state_dict = get_state_dict(self.model, self.optimizer, options=state_dict_options)
+ extra_state_dict = {
+ "lr_scheduler": self.lr_scheduler.state_dict(),
+ "rng": self.get_rng_state(),
+ }
+ print(f"[rank-{self.rank}]: Saving model to {os.path.abspath(model_path)}.")
+ print(f"[rank-{self.rank}]: Saving optimizer to {os.path.abspath(optim_path)}.")
+ print(f"[rank-{self.rank}]: Saving extra_state to {os.path.abspath(extra_path)}.")
+ self._save_shard(model_state_dict, model_path, "model")
+ self._save_shard(optim_state_dict, optim_path, "optimizer")
+ self._save_shard(extra_state_dict, extra_path, "extra_state")
+
+ # wait for everyone to dump to local
+ dist.barrier()
+
+ if self.rank == 0:
+ hf_path = os.path.join(path, "huggingface")
+ os.makedirs(hf_path, exist_ok=True)
+ assert isinstance(self.model._fsdp_wrapped_module, PreTrainedModel)
+ self.model._fsdp_wrapped_module.config.save_pretrained(hf_path)
+ self.model._fsdp_wrapped_module.generation_config.save_pretrained(hf_path)
+ self.processing_class.save_pretrained(hf_path)
+
+ dist.barrier()
diff --git a/code/verl/utils/dataset.py b/code/verl/utils/dataset.py
new file mode 100644
index 0000000000000000000000000000000000000000..7fab26630a9cb38348866bba66ff04928988750c
--- /dev/null
+++ b/code/verl/utils/dataset.py
@@ -0,0 +1,1044 @@
+# Copyright 2024 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.
+
+import inspect
+import json
+import math
+import os
+import time
+from collections import defaultdict
+from io import BytesIO
+from typing import Any, Optional, Union
+
+import numpy as np
+import torch
+from datasets import load_dataset
+from jinja2 import Template
+from PIL import Image
+from PIL.Image import Image as ImageObject
+from qwen_vl_utils.vision_process import fetch_video
+from torch.utils.data import Dataset, Sampler
+from transformers import PreTrainedTokenizer, ProcessorMixin
+
+from . import torch_functional as VF
+from .multimodal_contract import (
+ VIDEO_SOURCE_MODE_PREPROCESSED_ONLY,
+ VIDEO_SOURCE_MODE_REALTIME_ONLY,
+ build_video_multimodal_contract,
+ normalize_video_source_mode,
+ video_metadata_for_model,
+)
+from .prompt_template import build_prompt
+
+try:
+ _FETCH_VIDEO_PARAM_NAMES = frozenset(inspect.signature(fetch_video).parameters)
+except (TypeError, ValueError):
+ _FETCH_VIDEO_PARAM_NAMES = frozenset()
+
+
+def _cap_int(sample_value: Any, config_value: int) -> int:
+ try:
+ return min(int(sample_value), int(config_value))
+ except (TypeError, ValueError):
+ return config_value
+
+
+def _cap_optional_int(sample_value: Any, config_value: Optional[int]) -> Optional[int]:
+ if sample_value is None:
+ return config_value
+ if config_value is None:
+ try:
+ return int(sample_value)
+ except (TypeError, ValueError):
+ return None
+ return _cap_int(sample_value, config_value)
+
+
+def _fetch_video_with_retry(vision_info: dict[str, Any], fetch_kwargs: dict[str, Any]) -> Any:
+ """Retry transient FFmpeg/decord resource failures with path diagnostics."""
+ try:
+ attempts = max(1, int(os.getenv("MULTITASK_VIDEO_DECODE_RETRIES", "3")))
+ except ValueError:
+ attempts = 3
+ try:
+ retry_delay = max(0.0, float(os.getenv("MULTITASK_VIDEO_DECODE_RETRY_DELAY", "1.0")))
+ except ValueError:
+ retry_delay = 1.0
+
+ video_path = vision_info.get("video")
+ retryable_messages = (
+ "eagain",
+ "resource temporarily unavailable",
+ "failed scaling graph",
+ "failed initializing scaling graph",
+ "cannot create buffer source",
+ "errno 11",
+ )
+ for attempt in range(1, attempts + 1):
+ try:
+ return fetch_video(vision_info, **fetch_kwargs)
+ except Exception as error:
+ error_text = f"{type(error).__name__}: {error}".lower()
+ retryable = any(message in error_text for message in retryable_messages)
+ if not retryable:
+ raise
+ if attempt >= attempts:
+ raise RuntimeError(
+ f"Video decode failed after {attempts} attempts for {video_path!r}: {error}"
+ ) from error
+ delay = retry_delay * attempt
+ print(
+ f"[video-decode] transient failure {attempt}/{attempts} for "
+ f"{video_path!r}; retrying in {delay:.1f}s: {error}",
+ flush=True,
+ )
+ if delay > 0:
+ time.sleep(delay)
+
+ raise AssertionError("unreachable")
+
+
+def collate_fn(features: list[dict[str, Any]]) -> dict[str, Any]:
+ all_keys: set[str] = set()
+ for feature in features:
+ all_keys.update(feature.keys())
+
+ tensor_keys: set[str] = set()
+ non_tensor_keys: set[str] = set()
+ for feature in features:
+ for key, value in feature.items():
+ if isinstance(value, torch.Tensor):
+ tensor_keys.add(key)
+ else:
+ non_tensor_keys.add(key)
+
+ result: dict[str, Any] = {}
+ for key in tensor_keys:
+ result[key] = torch.stack([f[key] for f in features], dim=0)
+
+ for key in non_tensor_keys:
+ result[key] = np.array([f.get(key, None) for f in features], dtype=object)
+
+ return result
+
+
+class LocalJsonlDataset(Dataset):
+ """Random-access JSONL rows without imposing one Arrow schema.
+
+ Multitask training files legitimately carry task-specific side channels:
+ segmentation rows have mask metadata, temporal rows have spans, and
+ spatial-intelligence rows have subtype fields. Hugging Face Datasets infers
+ an Arrow schema from the first JSON chunk and rejects columns introduced by
+ later chunks. This lightweight index keeps each row as its original Python
+ mapping, while retaining random access for samplers and data-loader workers.
+ """
+
+ def __init__(
+ self,
+ path: str,
+ *,
+ offsets: Optional[list[int]] = None,
+ ) -> None:
+ self.path = os.path.abspath(path)
+ self._offsets = offsets if offsets is not None else self._index_rows()
+ self._handle = None
+ self._handle_pid: Optional[int] = None
+
+ def _index_rows(self) -> list[int]:
+ offsets: list[int] = []
+ with open(self.path, "rb") as handle:
+ while True:
+ offset = handle.tell()
+ line = handle.readline()
+ if not line:
+ break
+ if line.strip():
+ offsets.append(offset)
+ if not offsets:
+ raise ValueError(f"JSONL dataset is empty: {self.path}")
+ return offsets
+
+ def _reader(self):
+ pid = os.getpid()
+ if self._handle is None or self._handle_pid != pid or self._handle.closed:
+ if self._handle is not None and not self._handle.closed:
+ self._handle.close()
+ self._handle = open(self.path, "rb")
+ self._handle_pid = pid
+ return self._handle
+
+ def __len__(self) -> int:
+ return len(self._offsets)
+
+ def __getitem__(self, index: int) -> dict[str, Any]:
+ if index < 0:
+ index += len(self)
+ if index < 0 or index >= len(self):
+ raise IndexError(index)
+
+ handle = self._reader()
+ handle.seek(self._offsets[index])
+ raw = handle.readline()
+ try:
+ row = json.loads(raw)
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
+ raise ValueError(
+ f"Invalid JSONL row {index + 1} in {self.path}: {error}"
+ ) from error
+ if not isinstance(row, dict):
+ raise ValueError(
+ f"JSONL row {index + 1} in {self.path} must be an object."
+ )
+ return row
+
+ def __getstate__(self):
+ state = dict(self.__dict__)
+ state["_handle"] = None
+ state["_handle_pid"] = None
+ return state
+
+ def filter(
+ self,
+ function,
+ *,
+ desc: Optional[str] = None,
+ num_proc: Optional[int] = None,
+ ) -> "LocalJsonlDataset":
+ """Return a view containing rows accepted by ``function``.
+
+ Filtering remains deterministic and sequential. Video prompt filtering
+ is decode-heavy and should normally be completed during data
+ preparation; released recipes disable it at training startup.
+ """
+
+ del num_proc
+ if desc:
+ print(f"{desc} (local JSONL)")
+ selected = [
+ offset
+ for index, offset in enumerate(self._offsets)
+ if function(self[index])
+ ]
+ return LocalJsonlDataset(self.path, offsets=selected)
+
+
+class TaskGroupedBatchSampler(Sampler[list[int]]):
+ """Yields batches where all samples belong to the same task type.
+
+ Indices for each task group are shuffled independently each epoch, then
+ chunked into batches of ``batch_size``. Groups are interleaved in
+ round-robin order so that every task is visited roughly equally often.
+ Incomplete tail batches are dropped.
+ """
+
+ def __init__(
+ self,
+ dataset: Dataset,
+ batch_size: int,
+ task_key: str = "problem_type",
+ shuffle: bool = True,
+ seed: int = 42,
+ drop_last: bool = True,
+ ):
+ self.batch_size = batch_size
+ self.shuffle = shuffle
+ self.drop_last = drop_last
+ self.generator = torch.Generator()
+ self.generator.manual_seed(seed)
+
+ group_indices: dict[str, list[int]] = defaultdict(list)
+ for idx in range(len(dataset)):
+ row = dataset.dataset[idx]
+ if task_key not in row:
+ invalid_reason = "missing"
+ task = None
+ else:
+ task = row[task_key]
+ if task is None:
+ invalid_reason = "null"
+ elif not isinstance(task, str):
+ invalid_reason = f"non-string ({type(task).__name__})"
+ elif not task.strip():
+ invalid_reason = "empty"
+ else:
+ invalid_reason = None
+
+ if invalid_reason is not None:
+ raise ValueError(
+ f"TaskGroupedBatchSampler: row index {idx} has a {invalid_reason} "
+ f"task key {task_key!r} (value={task!r})."
+ )
+ group_indices[task].append(idx)
+
+ self.group_names = sorted(group_indices.keys())
+ self.group_indices = {k: group_indices[k] for k in self.group_names}
+
+ self._total_batches = 0
+ for indices in self.group_indices.values():
+ n = len(indices) // batch_size if drop_last else math.ceil(len(indices) / batch_size)
+ self._total_batches += n
+
+ groups_str = ", ".join(f"{k}({len(v)})" for k, v in self.group_indices.items())
+ print(
+ f"[TaskGroupedBatchSampler] {len(self.group_names)} groups: {groups_str} | "
+ f"batch_size={batch_size} | total_batches={self._total_batches}"
+ )
+
+ def __iter__(self):
+ group_batches: dict[str, list[list[int]]] = {}
+ for name, indices in self.group_indices.items():
+ idx = list(indices)
+ if self.shuffle:
+ perm = torch.randperm(len(idx), generator=self.generator).tolist()
+ idx = [idx[i] for i in perm]
+ batches = []
+ for i in range(0, len(idx), self.batch_size):
+ batch = idx[i : i + self.batch_size]
+ if self.drop_last and len(batch) < self.batch_size:
+ continue
+ batches.append(batch)
+ group_batches[name] = batches
+
+ queues = {name: iter(batches) for name, batches in group_batches.items()}
+ active_names = list(self.group_names)
+
+ while active_names:
+ next_round = []
+ for name in active_names:
+ batch = next(queues[name], None)
+ if batch is not None:
+ yield batch
+ next_round.append(name)
+ active_names = next_round
+
+ def __len__(self):
+ return self._total_batches
+
+
+def process_image(
+ image: Union[dict[str, Any], ImageObject, str], min_pixels: Optional[int], max_pixels: Optional[int]
+) -> ImageObject:
+ if isinstance(image, str):
+ image = Image.open(image)
+ elif isinstance(image, dict):
+ image = Image.open(BytesIO(image["bytes"]))
+ elif isinstance(image, bytes):
+ image = Image.open(BytesIO(image))
+
+ image.load() # avoid "Too many open files" errors
+ if max_pixels is not None and (image.width * image.height) > max_pixels:
+ resize_factor = math.sqrt(max_pixels / (image.width * image.height))
+ width, height = int(image.width * resize_factor), int(image.height * resize_factor)
+ image = image.resize((width, height))
+
+ if min_pixels is not None and (image.width * image.height) < min_pixels:
+ resize_factor = math.sqrt(min_pixels / (image.width * image.height))
+ width, height = int(image.width * resize_factor), int(image.height * resize_factor)
+ image = image.resize((width, height))
+
+ if image.mode != "RGB":
+ image = image.convert("RGB")
+
+ return image
+
+
+def _build_fallback_video_metadata(video_data: Union[torch.Tensor, list[ImageObject]], sample_fps: float) -> dict[str, Any]:
+ if isinstance(video_data, torch.Tensor):
+ total_num_frames = int(video_data.shape[0])
+ height = int(video_data.shape[-2]) if video_data.ndim >= 3 else None
+ width = int(video_data.shape[-1]) if video_data.ndim >= 3 else None
+ else:
+ total_num_frames = len(video_data)
+ height = width = None
+ if total_num_frames > 0:
+ first_frame = video_data[0]
+ if isinstance(first_frame, ImageObject):
+ width, height = first_frame.size
+ elif isinstance(first_frame, torch.Tensor) and first_frame.ndim >= 2:
+ height = int(first_frame.shape[-2])
+ width = int(first_frame.shape[-1])
+
+ metadata = {
+ "fps": float(sample_fps),
+ "frames_indices": list(range(total_num_frames)),
+ "total_num_frames": total_num_frames,
+ }
+ if sample_fps > 0:
+ metadata["duration"] = total_num_frames / sample_fps
+ if width is not None and height is not None:
+ metadata["width"] = width
+ metadata["height"] = height
+ return metadata
+
+
+def process_video(
+ video: Any,
+ min_pixels: int = 4 * 32 * 32,
+ max_pixels: int = 64 * 32 * 32,
+ max_frames: int = 128,
+ video_fps: float = 2.0,
+ total_pixels: Optional[int] = None,
+ return_fps: bool = False,
+) -> Any:
+ if isinstance(video, dict):
+ min_pixels = _cap_int(video.get("min_pixels"), min_pixels)
+ max_pixels = _cap_int(video.get("max_pixels"), max_pixels)
+ max_frames = _cap_int(video.get("max_frames"), max_frames)
+ video_fps = video.get("fps", video.get("video_fps", video_fps))
+ total_pixels = _cap_optional_int(video.get("total_pixels"), total_pixels)
+ video = video.get("video") or video.get("path")
+
+ if not isinstance(video, str) or not video.strip():
+ raise ValueError("Video entry must be a non-empty string or a dict with a non-empty 'video' or 'path' string.")
+
+ vision_info = {
+ "video": video,
+ "min_pixels": min_pixels,
+ "max_pixels": max_pixels,
+ "max_frames": max_frames,
+ "fps": video_fps,
+ }
+ if total_pixels is not None:
+ vision_info["total_pixels"] = total_pixels
+
+ fetch_kwargs = {}
+ # Qwen3-VL video processor reshapes frames with 16x16 patches.
+ # Ensure offline and online preprocessing resize to a 16-aligned grid.
+ if "image_patch_size" in _FETCH_VIDEO_PARAM_NAMES:
+ fetch_kwargs["image_patch_size"] = 16
+ elif "image_factor" in _FETCH_VIDEO_PARAM_NAMES:
+ fetch_kwargs["image_factor"] = 16
+ if return_fps and "return_video_sample_fps" in _FETCH_VIDEO_PARAM_NAMES:
+ fetch_kwargs["return_video_sample_fps"] = True
+ if return_fps and "return_video_metadata" in _FETCH_VIDEO_PARAM_NAMES:
+ fetch_kwargs["return_video_metadata"] = True
+
+ result = _fetch_video_with_retry(vision_info, fetch_kwargs)
+ if not return_fps:
+ return result
+
+ if isinstance(result, tuple) and len(result) == 2:
+ video_data, sample_fps = result
+ else:
+ video_data, sample_fps = result, video_fps
+
+ sample_fps = float(sample_fps)
+ if isinstance(video_data, tuple) and len(video_data) == 2:
+ return video_data, sample_fps
+
+ return (video_data, _build_fallback_video_metadata(video_data, sample_fps)), sample_fps
+
+
+def _video_path_from_entry(video: Any) -> str:
+ if isinstance(video, dict):
+ video = video.get("video") or video.get("path")
+ if not isinstance(video, str) or not video.strip():
+ raise ValueError("Video entry must contain a non-empty string path.")
+ return video
+
+
+def _resolve_video_entries(
+ videos: list[Any], image_dir: Optional[str] = None
+) -> tuple[list[Any], list[str]]:
+ resolved_entries: list[Any] = []
+ video_paths: list[str] = []
+ for video in videos:
+ video_path = _video_path_from_entry(video)
+ if image_dir is not None and not os.path.isabs(video_path):
+ video_path = os.path.join(image_dir, video_path)
+ video_paths.append(video_path)
+
+ if isinstance(video, dict):
+ resolved_video = dict(video)
+ resolved_video["video"] = video_path
+ resolved_entries.append(resolved_video)
+ else:
+ resolved_entries.append(video_path)
+
+ return resolved_entries, video_paths
+
+
+def _align_media_placeholders(
+ prompt: str,
+ marker: str,
+ media_count: int,
+) -> str:
+ """Match prompt placeholders to the media entries actually supplied.
+
+ Some legacy image-sequence rows retain placeholders for every source frame
+ even though their media list was capped during data preparation. Extra
+ placeholders have no corresponding pixels and must be removed before the
+ processor expands multimodal tokens. Missing placeholders are ambiguous and
+ remain a hard error instead of inventing an ordering.
+ """
+
+ placeholder_count = prompt.count(marker)
+ if placeholder_count < media_count:
+ raise ValueError(
+ f"Prompt has {placeholder_count} {marker} placeholder(s) for "
+ f"{media_count} media item(s)."
+ )
+ if placeholder_count == media_count:
+ return prompt
+
+ parts = prompt.split(marker)
+ return marker.join(parts[: media_count + 1]) + "".join(
+ parts[media_count + 1 :]
+ )
+
+
+class RLHFDataset(Dataset):
+ """
+ We assume the dataset contains a column that contains prompts and other information
+ """
+
+ def __init__(
+ self,
+ data_path: str,
+ tokenizer: PreTrainedTokenizer,
+ processor: Optional[ProcessorMixin],
+ prompt_key: str = "prompt",
+ answer_key: str = "answer",
+ image_key: str = "images",
+ video_key: str = "videos",
+ image_dir: Optional[str] = None,
+ video_fps: float = 2.0,
+ video_max_frames: int = 128,
+ max_prompt_length: int = 1024,
+ truncation: str = "error",
+ format_prompt: Optional[str] = None,
+ image_min_pixels: Optional[int] = None,
+ image_max_pixels: Optional[int] = None,
+ video_min_pixels: Optional[int] = None,
+ video_max_pixels: Optional[int] = None,
+ video_total_pixels: Optional[int] = None,
+ filter_overlong_prompts: bool = True,
+ filter_overlong_prompts_workers: int = 16,
+ use_preprocessed_videos: bool = True,
+ video_source_mode: Optional[str] = None,
+ preprocessed_video_dir: Optional[str] = None,
+ inline_video_tensors: bool = False,
+ enable_thinking: bool = False,
+ response_prefix: str = "",
+ model_type: Optional[str] = None,
+ ):
+ self.tokenizer = tokenizer
+ self.processor = processor
+ self.model_type = model_type
+ self.prompt_key = prompt_key
+ self.answer_key = answer_key
+ self.image_key = image_key
+ self.video_key = video_key
+ self.image_dir = image_dir
+ self.video_fps = video_fps
+ self.video_max_frames = video_max_frames
+ self.max_prompt_length = max_prompt_length
+ self.truncation = truncation
+ self.image_min_pixels = image_min_pixels
+ self.image_max_pixels = image_max_pixels
+ self.video_min_pixels = video_min_pixels
+ self.video_max_pixels = video_max_pixels
+ self.video_total_pixels = video_total_pixels
+ self.use_preprocessed_videos = use_preprocessed_videos
+ self.video_source_mode = normalize_video_source_mode(
+ video_source_mode,
+ use_preprocessed_videos=use_preprocessed_videos,
+ )
+ self.preprocessed_video_dir = preprocessed_video_dir
+ self.inline_video_tensors = bool(inline_video_tensors)
+ self.enable_thinking = bool(enable_thinking)
+ self.response_prefix = str(response_prefix or "")
+
+ if "@" in data_path:
+ data_path, data_split = data_path.split("@")
+ else:
+ data_split = "train"
+
+ if os.path.isdir(data_path):
+ # when we use dataset builder, we should always refer to the train split
+ file_type = os.path.splitext(os.listdir(data_path)[0])[-1][1:].replace("jsonl", "json")
+ self.dataset = load_dataset(file_type, data_dir=data_path, split=data_split)
+ elif os.path.isfile(data_path):
+ if data_path.casefold().endswith(".jsonl"):
+ self.dataset = LocalJsonlDataset(data_path)
+ else:
+ file_type = os.path.splitext(data_path)[-1][1:].replace("jsonl", "json")
+ self.dataset = load_dataset(file_type, data_files=data_path, split=data_split)
+ else:
+ # load remote dataset from huggingface hub
+ self.dataset = load_dataset(data_path, split=data_split)
+
+ self.format_prompt = None
+ if format_prompt:
+ with open(format_prompt, encoding="utf-8") as f:
+ self.format_prompt = f.read()
+
+ if filter_overlong_prompts:
+ self.dataset = self.dataset.filter(
+ self._filter_overlong_prompts,
+ desc="Filtering overlong prompts",
+ num_proc=filter_overlong_prompts_workers,
+ )
+
+ def _ensure_single_vision_modality(self, has_images: bool, has_videos: bool) -> None:
+ if has_images and has_videos:
+ raise NotImplementedError(
+ "A single sample containing both images and videos is not supported in this training contract yet."
+ )
+
+ def _append_response_prefix(self, prompt: str) -> str:
+ """Start generation from an explicit assistant prefix when configured."""
+ return prompt + self.response_prefix
+
+ def _resolve_preprocessed_video_path(self, example: dict[str, Any], *, pop_value: bool) -> Optional[str]:
+ if pop_value:
+ preprocessed_video_file = example.pop("preprocessed_video", None)
+ else:
+ preprocessed_video_file = example.get("preprocessed_video")
+
+ if not preprocessed_video_file:
+ if self.video_source_mode == VIDEO_SOURCE_MODE_PREPROCESSED_ONLY:
+ problem_id = example.get("problem_id", "unknown")
+ raise FileNotFoundError(
+ f"video_source_mode=preprocessed_only but sample {problem_id!r} has no preprocessed_video field."
+ )
+ return None
+
+ if self.preprocessed_video_dir is not None:
+ preprocessed_video_path = os.path.join(self.preprocessed_video_dir, preprocessed_video_file)
+ else:
+ preprocessed_video_path = preprocessed_video_file
+
+ if os.path.exists(preprocessed_video_path):
+ return preprocessed_video_path
+
+ if self.video_source_mode == VIDEO_SOURCE_MODE_PREPROCESSED_ONLY:
+ problem_id = example.get("problem_id", "unknown")
+ raise FileNotFoundError(
+ f"video_source_mode=preprocessed_only but artifact is missing for sample {problem_id!r}: "
+ f"{preprocessed_video_path}"
+ )
+
+ return None
+
+ def _should_use_preprocessed_video(self, preprocessed_video_path: Optional[str]) -> bool:
+ return (
+ preprocessed_video_path is not None
+ and self.video_source_mode != VIDEO_SOURCE_MODE_REALTIME_ONLY
+ )
+
+ def _build_messages(self, example: dict[str, Any]) -> list[dict[str, Any]]:
+ prompt_str: str = example[self.prompt_key]
+ if self.format_prompt:
+ format_prompt = Template(self.format_prompt.strip())
+ # Pass every example field to the Jinja template so it can route on
+ # problem_type, data_type, options, data_source, and so on.
+ prompt_str = format_prompt.render(
+ content=prompt_str, # older templates expect `content`
+ problem=prompt_str,
+ **{k: v for k, v in example.items() if k != self.prompt_key},
+ )
+ else:
+ # Only add task-specific instructions when no template was supplied;
+ # records that already carry full instructions would get them twice.
+ prompt_str = build_prompt(prompt_str, example)
+
+ # Accept both list and numpy.ndarray media columns.
+ images_data = example.get(self.image_key)
+ has_images = (
+ self.image_key in example
+ and images_data is not None
+ and hasattr(images_data, "__len__")
+ and len(images_data) > 0
+ )
+ # Check if videos exist and is a non-empty list/array
+ videos_data = example.get(self.video_key)
+ has_videos = (
+ self.video_key in example
+ and videos_data is not None
+ and hasattr(videos_data, "__len__")
+ and len(videos_data) > 0
+ )
+ self._ensure_single_vision_modality(has_images, has_videos)
+
+ if has_images:
+ prompt_str = _align_media_placeholders(
+ prompt_str,
+ "",
+ len(images_data),
+ )
+ # https://huggingface.co/docs/transformers/en/tasks/image_text_to_text
+ content_list = []
+ for i, content in enumerate(prompt_str.split("")):
+ if i != 0:
+ content_list.append({"type": "image"})
+
+ if content:
+ content_list.append({"type": "text", "text": content})
+
+ return [{"role": "user", "content": content_list}]
+ elif has_videos:
+ prompt_str = _align_media_placeholders(
+ prompt_str,
+ "",
+ len(videos_data),
+ )
+ content_list = []
+ for i, content in enumerate(prompt_str.split("")):
+ if i != 0:
+ content_list.append({"type": "video"})
+
+ if content:
+ content_list.append({"type": "text", "text": content})
+
+ return [{"role": "user", "content": content_list}]
+ else:
+ return [{"role": "user", "content": prompt_str}]
+
+ def _filter_overlong_prompts(self, example: dict[str, Any]) -> bool:
+ messages = self._build_messages(example)
+ # Accept both list and numpy.ndarray media columns.
+ images_data = example.get(self.image_key)
+ has_images = (
+ self.image_key in example
+ and images_data is not None
+ and hasattr(images_data, "__len__")
+ and len(images_data) > 0
+ )
+ # Check if videos exist and is a non-empty list/array
+ videos_data = example.get(self.video_key)
+ has_videos = (
+ self.video_key in example
+ and videos_data is not None
+ and hasattr(videos_data, "__len__")
+ and len(videos_data) > 0
+ )
+
+ if has_images:
+ prompt = self.processor.apply_chat_template(
+ messages, add_generation_prompt=True, tokenize=False, enable_thinking=self.enable_thinking
+ )
+ prompt = self._append_response_prefix(prompt)
+ images = example[self.image_key]
+ if self.image_dir is not None and len(images) != 0 and isinstance(images[0], str): # image paths
+ images = [os.path.join(self.image_dir, image) for image in images]
+
+ processed_images = [] if len(images) != 0 else None # text-only data
+ for image in images:
+ processed_images.append(process_image(image, self.image_min_pixels, self.image_max_pixels))
+
+ model_inputs = self.processor(processed_images, [prompt], add_special_tokens=False, return_tensors="pt")
+ return model_inputs["input_ids"].size(-1) <= self.max_prompt_length
+ elif has_videos:
+ prompt = self.processor.apply_chat_template(
+ messages, add_generation_prompt=True, tokenize=False, enable_thinking=self.enable_thinking
+ )
+ prompt = self._append_response_prefix(prompt)
+
+ preprocessed_video_path = self._resolve_preprocessed_video_path(example, pop_value=False)
+ if self._should_use_preprocessed_video(preprocessed_video_path):
+ preprocessed_data = torch.load(preprocessed_video_path, map_location="cpu", weights_only=False)
+ processed_videos = [preprocessed_data["frames"]]
+ video_metadatas = [
+ video_metadata_for_model(
+ preprocessed_data["metadata"],
+ preprocessed_data["frames"],
+ )
+ ]
+ model_inputs = self.processor(
+ videos=processed_videos,
+ text=[prompt],
+ add_special_tokens=False,
+ return_tensors="pt",
+ video_metadata=video_metadatas,
+ do_resize=False,
+ do_sample_frames=False,
+ )
+ return model_inputs["input_ids"].size(-1) <= self.max_prompt_length
+
+ # Fall back to decoding the source video at request time.
+ videos, _ = _resolve_video_entries(example[self.video_key], self.image_dir)
+
+ processed_videos = [] if len(videos) != 0 else None # text-only data
+ video_metadatas = []
+ for video in videos:
+ result = process_video(
+ video,
+ min_pixels=self.video_min_pixels if self.video_min_pixels else 4 * 32 * 32,
+ max_pixels=self.video_max_pixels if self.video_max_pixels else 64 * 32 * 32,
+ max_frames=self.video_max_frames,
+ video_fps=self.video_fps,
+ total_pixels=self.video_total_pixels,
+ return_fps=True,
+ )
+ if isinstance(result, tuple) and len(result) == 2:
+ video_data, _ = result # Unpack (video_data, sample_fps)
+ if isinstance(video_data, tuple) and len(video_data) == 2:
+ frames, metadata = video_data
+ processed_videos.append(frames)
+ video_metadatas.append(video_metadata_for_model(metadata, frames))
+ else:
+ processed_videos.append(video_data)
+ video_metadatas = None
+ break
+ else:
+ processed_videos.append(result)
+ video_metadatas = None
+ break
+
+ if video_metadatas is not None and len(video_metadatas) > 0:
+ model_inputs = self.processor(
+ videos=processed_videos,
+ text=[prompt],
+ add_special_tokens=False,
+ return_tensors="pt",
+ video_metadata=video_metadatas,
+ do_resize=False,
+ do_sample_frames=False,
+ )
+ else:
+ model_inputs = self.processor(
+ videos=processed_videos,
+ text=[prompt],
+ add_special_tokens=False,
+ return_tensors="pt",
+ do_sample_frames=False,
+ )
+ return model_inputs["input_ids"].size(-1) <= self.max_prompt_length
+ else:
+ prompt = self.tokenizer.apply_chat_template(
+ messages, add_generation_prompt=True, tokenize=False, enable_thinking=self.enable_thinking
+ )
+ prompt = self._append_response_prefix(prompt)
+ input_ids = self.tokenizer(prompt, add_special_tokens=False)["input_ids"]
+ return len(input_ids) <= self.max_prompt_length
+
+ def __len__(self):
+ return len(self.dataset)
+
+ def __getitem__(self, index):
+ example: dict = self.dataset[index]
+ messages = self._build_messages(example)
+ example.pop(self.prompt_key, None)
+
+ # Accept both list and numpy.ndarray media columns.
+ images_data = example.get(self.image_key)
+ has_images = (
+ self.image_key in example
+ and images_data is not None
+ and hasattr(images_data, "__len__")
+ and len(images_data) > 0
+ )
+ # Check if videos exist and is a non-empty list/array
+ videos_data = example.get(self.video_key)
+ has_videos = (
+ self.video_key in example
+ and videos_data is not None
+ and hasattr(videos_data, "__len__")
+ and len(videos_data) > 0
+ )
+
+ if has_images:
+ prompt = self.processor.apply_chat_template(
+ messages, add_generation_prompt=True, tokenize=False, enable_thinking=self.enable_thinking
+ )
+ prompt = self._append_response_prefix(prompt)
+ images = example.pop(self.image_key)
+ if self.image_dir is not None and len(images) != 0 and isinstance(images[0], str): # image paths
+ images = [os.path.join(self.image_dir, image) for image in images]
+
+ processed_images = [] if len(images) != 0 else None # text-only data
+ for image in images:
+ processed_images.append(process_image(image, self.image_min_pixels, self.image_max_pixels))
+
+ model_inputs = self.processor(processed_images, [prompt], add_special_tokens=False, return_tensors="pt")
+ input_ids = model_inputs.pop("input_ids")[0]
+ attention_mask = model_inputs.pop("attention_mask")[0]
+ example["multi_modal_data"] = {"images": images}
+ elif has_videos:
+ prompt = self.processor.apply_chat_template(
+ messages, add_generation_prompt=True, tokenize=False, enable_thinking=self.enable_thinking
+ )
+ prompt = self._append_response_prefix(prompt)
+ videos = example.pop(self.video_key)
+ videos, video_paths = _resolve_video_entries(videos, self.image_dir)
+
+ preprocessed_video_path = self._resolve_preprocessed_video_path(example, pop_value=True)
+ if self._should_use_preprocessed_video(preprocessed_video_path):
+ preprocessed_data = torch.load(preprocessed_video_path, map_location="cpu", weights_only=False)
+ processed_videos = [preprocessed_data["frames"]]
+ video_metadatas = [
+ video_metadata_for_model(
+ preprocessed_data["metadata"],
+ preprocessed_data["frames"],
+ )
+ ]
+ video_kwargs = {"do_sample_frames": False}
+ else:
+ processed_videos = [] if len(videos) != 0 else None # text-only data
+ video_kwargs = {"do_sample_frames": False} # For Qwen3-VL
+ for video in videos:
+ processed_video, _ = process_video(
+ video,
+ min_pixels=self.video_min_pixels if self.video_min_pixels else 4 * 32 * 32,
+ max_pixels=self.video_max_pixels if self.video_max_pixels else 64 * 32 * 32,
+ max_frames=self.video_max_frames,
+ video_fps=self.video_fps,
+ total_pixels=self.video_total_pixels,
+ return_fps=True,
+ )
+ processed_videos.append(processed_video)
+
+ # Handle video_metadata for Qwen3-VL
+ if processed_videos is not None and len(processed_videos) > 0:
+ # video_metadatas is already set when a preprocessed tensor was loaded.
+ if "video_metadatas" in locals() and video_metadatas is not None and len(video_metadatas) > 0:
+ # Preprocessed video: processed_videos already holds the frames.
+ processed_video_frames = processed_videos
+ else:
+ # Realtime path: process_video returns (frames, metadata) when return_fps=True.
+ # Historical runs normalize metadata against decoded frames.
+ # Upstream parity preserves original fps/frame indices so Qwen3-VL
+ # renders the exact upstream timestamp tokens. Apply the selected
+ # contract at the source so input ids, rollout and FSDP forward agree.
+ processed_video_frames = []
+ video_metadatas = []
+ for pv in processed_videos:
+ if isinstance(pv, tuple) and len(pv) == 2:
+ frames, metadata = pv
+ processed_video_frames.append(frames)
+ video_metadatas.append(video_metadata_for_model(metadata, frames))
+ else:
+ processed_video_frames.append(pv)
+ video_metadatas = None
+ break
+
+ if video_metadatas is not None and len(video_metadatas) > 0:
+ model_inputs = self.processor(
+ text=[prompt],
+ videos=processed_video_frames,
+ add_special_tokens=False,
+ video_metadata=video_metadatas,
+ return_tensors="pt",
+ do_resize=False,
+ **video_kwargs,
+ )
+ else:
+ model_inputs = self.processor(
+ videos=processed_video_frames,
+ text=[prompt],
+ add_special_tokens=False,
+ return_tensors="pt",
+ **video_kwargs,
+ )
+ else:
+ model_inputs = self.processor(
+ videos=processed_videos,
+ text=[prompt],
+ add_special_tokens=False,
+ return_tensors="pt",
+ )
+
+ input_ids = model_inputs.pop("input_ids")[0]
+ attention_mask = model_inputs.pop("attention_mask")[0]
+
+ inline_frames = None
+ inline_metadatas = None
+ if (
+ self.inline_video_tensors
+ and "processed_video_frames" in locals()
+ and processed_video_frames is not None
+ and len(processed_video_frames) > 0
+ and "video_metadatas" in locals()
+ and video_metadatas is not None
+ and len(video_metadatas) == len(processed_video_frames)
+ ):
+ inline_frames = processed_video_frames
+ inline_metadatas = video_metadatas
+
+ example["multi_modal_data"] = build_video_multimodal_contract(
+ video_paths=video_paths,
+ preprocessed_video_path=preprocessed_video_path,
+ video_source_mode=self.video_source_mode,
+ inline_frames=inline_frames,
+ inline_metadatas=inline_metadatas,
+ )
+ else:
+ # Text-only sample, with neither images nor videos.
+ prompt = self.tokenizer.apply_chat_template(
+ messages, add_generation_prompt=True, tokenize=False, enable_thinking=self.enable_thinking
+ )
+ prompt = self._append_response_prefix(prompt)
+ model_inputs = self.tokenizer([prompt], add_special_tokens=False, return_tensors="pt")
+ input_ids = model_inputs.pop("input_ids")[0]
+ attention_mask = model_inputs.pop("attention_mask")[0]
+ # Text-only rows still need an empty dict so the batch keys stay
+ # uniform; None would break vllm_rollout.
+ example["multi_modal_data"] = {}
+
+ # Clean up images/videos keys if they still exist
+ if self.image_key in example:
+ example.pop(self.image_key, None)
+ if self.video_key in example:
+ example.pop(self.video_key, None)
+
+ if self.processor is not None and "Qwen2VLImageProcessor" in self.processor.image_processor.__class__.__name__:
+ # qwen-vl mrope
+ # Qwen3.5 and Qwen3-VL share the same Qwen3VLProcessor,
+ # so we distinguish by model_type when available
+ if self.model_type == "qwen3_5":
+ from ..models.transformers.qwen3_5 import get_rope_index
+ elif "Qwen3VLProcessor" in self.processor.__class__.__name__:
+ from ..models.transformers.qwen3_vl import get_rope_index
+ else:
+ from ..models.transformers.qwen2_vl import get_rope_index
+
+ vision_position_ids = get_rope_index(
+ self.processor,
+ input_ids=input_ids,
+ image_grid_thw=model_inputs.get("image_grid_thw", None),
+ video_grid_thw=model_inputs.get("video_grid_thw", None),
+ second_per_grid_ts=model_inputs.get("second_per_grid_ts", None),
+ attention_mask=attention_mask,
+ ) # (3, seq_length)
+ text_position_ids = torch.arange(len(input_ids)).unsqueeze(0) # (1, seq_length)
+ position_ids = torch.cat((text_position_ids, vision_position_ids), dim=0) # (4, seq_length)
+ else:
+ position_ids = torch.clip(attention_mask.cumsum(dim=0) - 1, min=0, max=None) # (seq_length,)
+
+ input_ids, attention_mask, position_ids = VF.postprocess_data(
+ input_ids=input_ids,
+ attention_mask=attention_mask,
+ position_ids=position_ids,
+ max_length=self.max_prompt_length,
+ pad_token_id=self.tokenizer.pad_token_id,
+ left_pad=True,
+ truncation=self.truncation,
+ )
+ raw_prompt_ids = self.tokenizer.encode(prompt, add_special_tokens=False)
+ if len(raw_prompt_ids) > self.max_prompt_length:
+ if self.truncation == "left":
+ raw_prompt_ids = raw_prompt_ids[-self.max_prompt_length :]
+ elif self.truncation == "right":
+ raw_prompt_ids = raw_prompt_ids[: self.max_prompt_length]
+ elif self.truncation == "error":
+ raise RuntimeError(f"Prompt length {len(raw_prompt_ids)} is longer than {self.max_prompt_length}.")
+
+ example["input_ids"] = input_ids
+ example["attention_mask"] = attention_mask
+ example["position_ids"] = position_ids
+ example["raw_prompt_ids"] = raw_prompt_ids
+ example["ground_truth"] = example.pop(self.answer_key)
+
+ return example
diff --git a/code/verl/utils/flops_counter.py b/code/verl/utils/flops_counter.py
new file mode 100644
index 0000000000000000000000000000000000000000..be9ff956e2916de87a2c3314ab499ed903a2dae7
--- /dev/null
+++ b/code/verl/utils/flops_counter.py
@@ -0,0 +1,177 @@
+# Copyright 2024 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 typing import TYPE_CHECKING, List, Tuple
+
+import torch
+
+
+if TYPE_CHECKING:
+ from transformers.models.llama.configuration_llama import LlamaConfig
+
+
+def get_device_flops(unit: str = "T") -> float:
+ def unit_convert(number: float, level: str):
+ units = ["B", "K", "M", "G", "T", "P"]
+ if number <= 0:
+ return number
+
+ ptr = 0
+ while ptr < len(units) and units[ptr] != level:
+ number /= 1000
+ ptr += 1
+
+ return number
+
+ device_name = torch.cuda.get_device_name()
+ flops = float("inf") # INF flops for unkown gpu type
+ if "H100" in device_name or "H800" in device_name:
+ flops = 989e12
+ elif "A100" in device_name or "A800" in device_name:
+ flops = 312e12
+ elif "L40" in device_name:
+ flops = 181.05e12
+ elif "L20" in device_name:
+ flops = 119.5e12
+ elif "H20" in device_name:
+ flops = 148e12
+ elif "910B" in device_name:
+ flops = 354e12
+
+ flops_unit = unit_convert(flops, unit)
+ return flops_unit
+
+
+class FlopsCounter:
+ """
+ Used to count mfu during training loop
+
+ Example:
+ flops_counter = FlopsCounter(config)
+ flops_achieved, flops_promised = flops_counter.estimate_flops(tokens_list, delta_time)
+ """
+
+ def __init__(self, config: "LlamaConfig"):
+ _ESTIMATE_FUNC = {
+ "llama": self._estimate_llama_flops,
+ "qwen2": self._estimate_llama_flops,
+ "qwen2_moe": self._estimate_qwen2_moe_flops,
+ "qwen2_vl": self._estimate_llama_flops,
+ "qwen2_5_vl": self._estimate_llama_flops,
+ "qwen3": self._estimate_llama_flops,
+ "qwen3_vl": self._estimate_llama_flops,
+ "qwen3_moe": self._estimate_qwen2_moe_flops,
+ "qwen3_vl_moe": self._estimate_qwen2_moe_flops,
+ "qwen3_5": self._estimate_llama_flops,
+ }
+
+ if config.model_type not in _ESTIMATE_FUNC:
+ print(f"Only support {_ESTIMATE_FUNC.keys()}, but got {config.model_type}. MFU will always be zero.")
+
+ self.config = getattr(config, "text_config", config)
+ self._estimate_flops = _ESTIMATE_FUNC.get(config.model_type, self._estimate_unknown_flops)
+
+ def _estimate_unknown_flops(self, tokens_sum: int, batch_seqlens: List[int], delta_time: float) -> float:
+ return 0
+
+ def _estimate_llama_flops(self, tokens_sum: int, batch_seqlens: List[int], delta_time: float) -> float:
+ config = self.config
+ hidden_size = config.hidden_size
+ vocab_size = config.vocab_size
+ num_hidden_layers = config.num_hidden_layers
+ num_key_value_heads = config.num_key_value_heads
+ num_attention_heads = config.num_attention_heads
+ intermediate_size = config.intermediate_size
+
+ head_dim = getattr(config, "head_dim", hidden_size // num_attention_heads)
+ q_size = num_attention_heads * head_dim
+ k_size = num_key_value_heads * head_dim
+ v_size = num_key_value_heads * head_dim
+
+ # non-attn per layer parm
+ # Qwen2/LLama use SwiGelu, gate, having up and down linear layer in mlp
+ mlp_N = hidden_size * intermediate_size * 3
+ attn_linear_N = hidden_size * (q_size + k_size + v_size + num_attention_heads * head_dim)
+ emd_and_lm_head_N = vocab_size * hidden_size * 2
+ # non-attn all_layer parm
+ dense_N = (mlp_N + attn_linear_N) * num_hidden_layers + emd_and_lm_head_N
+ # non-attn all_layer & all_token fwd & bwd flops
+ dense_N_flops = 6 * dense_N * tokens_sum
+
+ # attn all_layer & all_token fwd & bwd flops
+ seqlen_square_sum = 0
+ for seqlen in batch_seqlens:
+ seqlen_square_sum += seqlen * seqlen
+
+ attn_qkv_flops = 12 * seqlen_square_sum * head_dim * num_attention_heads * num_hidden_layers
+
+ # all_layer & all_token fwd & bwd flops
+ flops_all_token = dense_N_flops + attn_qkv_flops
+ flops_achieved = flops_all_token * (1.0 / delta_time) / 1e12
+ return flops_achieved
+
+ def _estimate_qwen2_moe_flops(self, tokens_sum: int, batch_seqlens: List[int], delta_time: float) -> float:
+ config = self.config
+ hidden_size = config.hidden_size
+ vocab_size = config.vocab_size
+ num_hidden_layers = config.num_hidden_layers
+ num_key_value_heads = config.num_key_value_heads
+ num_attention_heads = config.num_attention_heads
+ moe_intermediate_size = config.moe_intermediate_size
+ moe_topk = config.num_experts_per_tok
+ num_experts = config.num_experts
+
+ head_dim = getattr(config, "head_dim", hidden_size // num_attention_heads)
+ q_size = num_attention_heads * head_dim
+ k_size = num_key_value_heads * head_dim
+ v_size = num_key_value_heads * head_dim
+
+ # non-attn per layer parm
+ # gate + moe export
+ moe_mlp_N = hidden_size * moe_topk * moe_intermediate_size * 3 + hidden_size * num_experts
+ attn_linear_N = hidden_size * (q_size + k_size + v_size + num_attention_heads * head_dim)
+ emd_and_lm_head_N = vocab_size * hidden_size * 2
+ # non-attn all_layer parm
+ dense_N = (moe_mlp_N + attn_linear_N) * num_hidden_layers + emd_and_lm_head_N
+ # non-attn all_layer & all_token fwd & bwd flops
+ dense_N_flops = 6 * dense_N * tokens_sum
+
+ # attn all_layer & all_token fwd & bwd flops
+ seqlen_square_sum = 0
+ for seqlen in batch_seqlens:
+ seqlen_square_sum += seqlen * seqlen
+
+ attn_qkv_flops = 12 * seqlen_square_sum * head_dim * num_attention_heads * num_hidden_layers
+
+ # all_layer & all_token fwd & bwd flops
+ flops_all_token = dense_N_flops + attn_qkv_flops
+ flops_achieved = flops_all_token * (1.0 / delta_time) / 1e12
+ return flops_achieved
+
+ def estimate_flops(self, batch_seqlens: List[int], delta_time: float) -> Tuple[float, float]:
+ """
+ Estimate the FLOPS based on the number of valid tokens in the current batch and the time taken.
+
+ Args:
+ batch_seqlens (List[int]): A list where each element represents the number of valid tokens in the current batch.
+ delta_time (float): The time taken to process the batch, in seconds.
+
+ Returns:
+ estimated_flops (float): The estimated FLOPS based on the input tokens and time.
+ promised_flops (float): The expected FLOPS of the current device.
+ """
+ tokens_sum = sum(batch_seqlens)
+ estimated_flops = self._estimate_flops(tokens_sum, batch_seqlens, delta_time)
+ promised_flops = get_device_flops()
+ return estimated_flops, promised_flops
diff --git a/code/verl/utils/fsdp_utils.py b/code/verl/utils/fsdp_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ca563a755160705f96f3e3176af6b16ce510be6
--- /dev/null
+++ b/code/verl/utils/fsdp_utils.py
@@ -0,0 +1,140 @@
+# Copyright 2024 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.
+
+import gc
+from collections import defaultdict
+from functools import partial
+from typing import Callable, Union
+
+import torch
+from torch import nn
+from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
+from torch.distributed.fsdp._runtime_utils import _lazy_init
+from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
+from torch.optim import Optimizer
+from transformers import PreTrainedModel
+from transformers.trainer_pt_utils import get_module_class_from_name
+
+
+def get_init_fn(model: nn.Module, device: Union[str, torch.device]) -> Callable[[nn.Module], None]:
+ param_occurrence = defaultdict(int)
+ for _, param in model.named_parameters(remove_duplicate=False):
+ param_occurrence[param] += 1
+
+ duplicated_params = {param for param in param_occurrence.keys() if param_occurrence[param] > 1}
+ materialized_params = {}
+
+ def init_fn(module: nn.Module):
+ for name, param in module.named_parameters(recurse=False):
+ if param in duplicated_params:
+ module._parameters[name] = materialized_params.setdefault(
+ param, nn.Parameter(torch.empty_like(param.data, device=device), requires_grad=param.requires_grad)
+ )
+ else:
+ module._parameters[name] = nn.Parameter(
+ torch.empty_like(param.data, device=device), requires_grad=param.requires_grad
+ )
+
+ return init_fn
+
+
+def get_fsdp_wrap_policy(model: PreTrainedModel):
+ """Get FSDP wrap policy for the model.
+
+ Args:
+ module: The module to get wrap policy for
+ """
+ transformer_cls_to_wrap = set()
+ for module in model._no_split_modules:
+ transformer_cls = get_module_class_from_name(model, module)
+ if transformer_cls is None:
+ raise Exception(f"Cannot find {module} in pretrained model.")
+ else:
+ transformer_cls_to_wrap.add(transformer_cls)
+
+ return partial(transformer_auto_wrap_policy, transformer_layer_cls=transformer_cls_to_wrap)
+
+
+@torch.no_grad()
+def offload_fsdp_model(model: FSDP, empty_cache: bool = True):
+ # lazy init FSDP model
+ _lazy_init(model, model)
+ assert model._is_root, "Only support root model offloading to CPU"
+ for handle in model._all_handles:
+ if handle._offload_params:
+ continue
+
+ flat_param = handle.flat_param
+ assert (
+ flat_param.data.data_ptr() == flat_param._local_shard.data_ptr()
+ and id(flat_param.data) != id(flat_param._local_shard)
+ and flat_param.data.size() == flat_param._local_shard.size()
+ )
+ handle.flat_param_to("cpu", non_blocking=True)
+ # the following still keeps id(._local_shard) != id(.data)
+ flat_param._local_shard = flat_param.data
+ assert id(flat_param._local_shard) != id(flat_param.data)
+
+ if empty_cache:
+ torch.cuda.empty_cache()
+
+
+@torch.no_grad()
+def load_fsdp_model(model: FSDP, empty_cache: bool = True):
+ # lazy init FSDP model
+ _lazy_init(model, model)
+ assert model._is_root, "Only support root model loading to GPU"
+ for handle in model._all_handles:
+ if handle._offload_params:
+ continue
+
+ flat_param = handle.flat_param
+ handle.flat_param_to("cuda", non_blocking=True)
+ # the following still keeps id(._local_shard) != id(.data)
+ flat_param._local_shard = flat_param.data
+
+ if empty_cache:
+ gc.collect()
+
+
+@torch.no_grad()
+def offload_fsdp_optimizer(optimizer: Optimizer, empty_cache: bool = True):
+ if not optimizer.state:
+ return
+
+ for param_group in optimizer.param_groups:
+ for param in param_group["params"]:
+ state = optimizer.state[param]
+ for key, value in state.items():
+ if isinstance(value, torch.Tensor):
+ state[key] = value.to("cpu", non_blocking=True)
+
+ if empty_cache:
+ torch.cuda.empty_cache()
+
+
+@torch.no_grad()
+def load_fsdp_optimizer(optimizer: Optimizer, empty_cache: bool = True):
+ if not optimizer.state:
+ return
+
+ for param_group in optimizer.param_groups:
+ for param in param_group["params"]:
+ state = optimizer.state[param]
+ for key, value in state.items():
+ if isinstance(value, torch.Tensor):
+ state[key] = value.to("cuda", non_blocking=True)
+
+ if empty_cache:
+ gc.collect()
diff --git a/code/verl/utils/logger/__init__.py b/code/verl/utils/logger/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..557c47750000363fc62c8b9fea11c9299b3d901f
--- /dev/null
+++ b/code/verl/utils/logger/__init__.py
@@ -0,0 +1,19 @@
+# Copyright 2024 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 .logger import Tracker
+
+
+__all__ = ["Tracker"]
diff --git a/code/verl/utils/logger/gen_logger.py b/code/verl/utils/logger/gen_logger.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e61a9a5a56498e5f16a0b84fc2975fb28ebb200
--- /dev/null
+++ b/code/verl/utils/logger/gen_logger.py
@@ -0,0 +1,144 @@
+# Copyright 2024 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.
+
+
+import os
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import Any, List, Optional, Tuple, Union
+
+from ..py_functional import is_package_available
+
+
+if is_package_available("wandb"):
+ import wandb # type: ignore
+
+
+if is_package_available("swanlab"):
+ import swanlab # type: ignore
+
+
+GenerationSample = Union[Tuple[str, str, str, float], Tuple[str, str, str, float, Any]]
+
+
+def _unpack_generation_sample(sample: GenerationSample) -> Tuple[str, str, str, float, Any]:
+ """Unify generation sample shape with backward compatibility."""
+ if len(sample) == 4:
+ inp, out, lab, score = sample
+ return inp, out, lab, score, None
+ if len(sample) == 5:
+ inp, out, lab, score, problem_id = sample
+ return inp, out, lab, score, problem_id
+ raise ValueError(f"Invalid generation sample format with length={len(sample)}.")
+
+
+@dataclass
+class GenerationLogger(ABC):
+ config: dict[str, Any]
+
+ @abstractmethod
+ def log(self, samples: List[GenerationSample], step: int) -> None: ...
+
+
+@dataclass
+class ConsoleGenerationLogger(GenerationLogger):
+ def log(self, samples: List[GenerationSample], step: int) -> None:
+ for sample in samples:
+ inp, out, lab, score, problem_id = _unpack_generation_sample(sample)
+ print(
+ f"[problem_id] {problem_id}\n[prompt] {inp}\n[output] {out}\n[ground_truth] {lab}\n[score] {score}\n"
+ )
+
+
+@dataclass
+class FileGenerationLogger(GenerationLogger):
+ def log(self, samples: List[GenerationSample], step: int) -> None:
+ with open(os.path.join(self.config["trainer"]["save_checkpoint_path"], "generations.log"), "a") as f:
+ for sample in samples:
+ inp, out, lab, score, problem_id = _unpack_generation_sample(sample)
+ f.write(
+ f"[problem_id] {problem_id}\n[prompt] {inp}\n[output] {out}\n[ground_truth] {lab}\n[score] {score}\n\n"
+ )
+
+
+@dataclass
+class WandbGenerationLogger(GenerationLogger):
+ def log(self, samples: List[GenerationSample], step: int) -> None:
+ # Create column names for all samples
+ columns = ["step"] + sum(
+ [
+ [f"problem_id_{i + 1}", f"input_{i + 1}", f"output_{i + 1}", f"label_{i + 1}", f"score_{i + 1}"]
+ for i in range(len(samples))
+ ],
+ [],
+ )
+
+ if not hasattr(self, "validation_table"):
+ # Initialize the table on first call
+ self.validation_table = wandb.Table(columns=columns)
+
+ # Create a new table with same columns and existing data
+ # Workaround for https://github.com/wandb/wandb/issues/2981#issuecomment-1997445737
+ new_table = wandb.Table(columns=columns, data=self.validation_table.data)
+
+ # Add new row with all data
+ row_data = [step]
+ for sample in samples:
+ inp, out, lab, score, problem_id = _unpack_generation_sample(sample)
+ row_data.extend([problem_id, inp, out, lab, score])
+
+ new_table.add_data(*row_data)
+ wandb.log({"val/generations": new_table}, step=step)
+ self.validation_table = new_table
+
+
+@dataclass
+class SwanlabGenerationLogger(GenerationLogger):
+ def log(self, samples: List[GenerationSample], step: int) -> None:
+ swanlab_text_list = []
+ for i, sample in enumerate(samples):
+ inp, out, lab, score, problem_id = _unpack_generation_sample(sample)
+ row_text = "\n\n---\n\n".join(
+ (
+ f"problem_id: {problem_id}",
+ f"input: {inp}",
+ f"output: {out}",
+ f"label: {lab}",
+ f"score: {score}",
+ )
+ )
+ swanlab_text_list.append(swanlab.Text(row_text, caption=f"sample {i + 1}"))
+
+ swanlab.log({"val/generations": swanlab_text_list}, step=step)
+
+
+GEN_LOGGERS = {
+ "console": ConsoleGenerationLogger,
+ "file": FileGenerationLogger,
+ "wandb": WandbGenerationLogger,
+ "swanlab": SwanlabGenerationLogger,
+}
+
+
+class AggregateGenerationsLogger:
+ def __init__(self, loggers: List[str], config: Optional[dict[str, Any]] = None):
+ self.loggers: List[GenerationLogger] = []
+
+ for logger in loggers:
+ if logger in GEN_LOGGERS:
+ self.loggers.append(GEN_LOGGERS[logger](config))
+
+ def log(self, samples: List[GenerationSample], step: int) -> None:
+ for logger in self.loggers:
+ logger.log(samples, step)
diff --git a/code/verl/utils/logger/logger.py b/code/verl/utils/logger/logger.py
new file mode 100644
index 0000000000000000000000000000000000000000..17cd819fe1052f99c2a767970c3a41cf60cf31c0
--- /dev/null
+++ b/code/verl/utils/logger/logger.py
@@ -0,0 +1,189 @@
+# Copyright 2024 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.
+"""
+A unified tracking interface that supports logging data to different backend
+"""
+
+import json
+import os
+from abc import ABC, abstractmethod
+from typing import Any, Optional, Union
+
+import torch
+
+from ..py_functional import convert_dict_to_str, flatten_dict, is_package_available, unflatten_dict
+from .gen_logger import AggregateGenerationsLogger, GenerationSample
+
+
+if is_package_available("mlflow"):
+ import mlflow # type: ignore
+
+
+if is_package_available("tensorboard"):
+ from torch.utils.tensorboard import SummaryWriter
+
+
+if is_package_available("wandb"):
+ import wandb # type: ignore
+
+
+if is_package_available("swanlab"):
+ import swanlab # type: ignore
+
+
+class Logger(ABC):
+ @abstractmethod
+ def __init__(self, config: dict[str, Any]) -> None: ...
+
+ @abstractmethod
+ def log(self, data: dict[str, Any], step: int) -> None: ...
+
+ def finish(self) -> None:
+ pass
+
+
+class ConsoleLogger(Logger):
+ def __init__(self, config: dict[str, Any]) -> None:
+ print("Config\n" + convert_dict_to_str(config))
+
+ def log(self, data: dict[str, Any], step: int) -> None:
+ print(f"Step {step}\n" + convert_dict_to_str(unflatten_dict(data)))
+
+
+class FileLogger(Logger):
+ def __init__(self, config: dict[str, Any]) -> None:
+ self.config = config
+ print(f"Initializing logging file to {config['trainer']['save_checkpoint_path']}.")
+ os.makedirs(config["trainer"]["save_checkpoint_path"], exist_ok=True)
+ with open(os.path.join(config["trainer"]["save_checkpoint_path"], "experiment_config.json"), "w") as f:
+ json.dump(config, f, indent=2)
+
+ with open(os.path.join(config["trainer"]["save_checkpoint_path"], "experiment_log.jsonl"), "w") as f:
+ pass
+
+ with open(os.path.join(config["trainer"]["save_checkpoint_path"], "generations.log"), "w") as f:
+ pass
+
+ def log(self, data: dict[str, Any], step: int) -> None:
+ with open(os.path.join(self.config["trainer"]["save_checkpoint_path"], "experiment_log.jsonl"), "a") as f:
+ f.write(json.dumps({"step": step, **unflatten_dict(data)}) + "\n")
+
+
+class MlflowLogger(Logger):
+ def __init__(self, config: dict[str, Any]) -> None:
+ mlflow.start_run(run_name=config["trainer"]["experiment_name"])
+ mlflow.log_params(flatten_dict(config))
+
+ def log(self, data: dict[str, Any], step: int) -> None:
+ mlflow.log_metrics(metrics=data, step=step)
+
+
+class SwanlabLogger(Logger):
+ def __init__(self, config: dict[str, Any]) -> None:
+ swanlab_key = os.getenv("SWANLAB_API_KEY")
+ swanlab_dir = os.getenv("SWANLAB_DIR", "swanlab_log")
+ swanlab_mode = os.getenv("SWANLAB_MODE", "cloud")
+ if swanlab_key:
+ swanlab.login(swanlab_key)
+
+ swanlab.init(
+ project=config["trainer"]["project_name"],
+ experiment_name=config["trainer"]["experiment_name"],
+ config={"UPPERFRAMEWORK": "OraRL", "FRAMEWORK": "veRL", **config},
+ logdir=swanlab_dir,
+ mode=swanlab_mode,
+ )
+
+ def log(self, data: dict[str, Any], step: int) -> None:
+ swanlab.log(data=data, step=step)
+
+ def finish(self) -> None:
+ swanlab.finish()
+
+
+class TensorBoardLogger(Logger):
+ def __init__(self, config: dict[str, Any]) -> None:
+ tensorboard_dir = os.getenv("TENSORBOARD_DIR", "tensorboard_log")
+ tensorboard_dir = os.path.join(
+ tensorboard_dir, config["trainer"]["project_name"], config["trainer"]["experiment_name"]
+ )
+ os.makedirs(tensorboard_dir, exist_ok=True)
+ print(f"Saving tensorboard log to {tensorboard_dir}.")
+ self.writer = SummaryWriter(tensorboard_dir)
+ config_dict = {}
+ for key, value in flatten_dict(config).items():
+ if isinstance(value, (int, float, str, bool, torch.Tensor)):
+ config_dict[key] = value
+ else:
+ config_dict[key] = str(value)
+
+ self.writer.add_hparams(hparam_dict=config_dict, metric_dict={"placeholder": 0})
+
+ def log(self, data: dict[str, Any], step: int) -> None:
+ for key, value in data.items():
+ self.writer.add_scalar(key, value, step)
+
+ def finish(self):
+ self.writer.close()
+
+
+class WandbLogger(Logger):
+ def __init__(self, config: dict[str, Any]) -> None:
+ wandb.init(
+ project=config["trainer"]["project_name"],
+ name=config["trainer"]["experiment_name"],
+ config=config,
+ )
+
+ def log(self, data: dict[str, Any], step: int) -> None:
+ wandb.log(data=data, step=step)
+
+ def finish(self) -> None:
+ wandb.finish()
+
+
+LOGGERS = {
+ "console": ConsoleLogger,
+ "file": FileLogger,
+ "mlflow": MlflowLogger,
+ "swanlab": SwanlabLogger,
+ "tensorboard": TensorBoardLogger,
+ "wandb": WandbLogger,
+}
+
+
+class Tracker:
+ def __init__(self, loggers: Union[str, list[str]] = "console", config: Optional[dict[str, Any]] = None):
+ if isinstance(loggers, str):
+ loggers = [loggers]
+
+ self.loggers: list[Logger] = []
+ for logger in loggers:
+ if logger not in LOGGERS:
+ raise ValueError(f"{logger} is not supported.")
+
+ self.loggers.append(LOGGERS[logger](config))
+
+ self.gen_logger = AggregateGenerationsLogger(loggers, config)
+
+ def log(self, data: dict[str, Any], step: int) -> None:
+ for logger in self.loggers:
+ logger.log(data=data, step=step)
+
+ def log_generation(self, samples: list[GenerationSample], step: int) -> None:
+ self.gen_logger.log(samples, step)
+
+ def __del__(self):
+ for logger in self.loggers:
+ logger.finish()
diff --git a/code/verl/utils/model_utils.py b/code/verl/utils/model_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..71d4fe2c4e9c481972242d5b20cf82e669f68e30
--- /dev/null
+++ b/code/verl/utils/model_utils.py
@@ -0,0 +1,73 @@
+# Copyright 2024 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.
+"""
+Utilities to create common models
+"""
+
+from functools import lru_cache
+from typing import Optional, Tuple
+
+import torch
+import torch.distributed as dist
+from torch import nn
+
+
+@lru_cache
+def is_rank0() -> int:
+ return (not dist.is_initialized()) or (dist.get_rank() == 0)
+
+
+def print_gpu_memory_usage(prefix: str = "GPU memory usage") -> None:
+ """Report the current GPU VRAM usage."""
+ if is_rank0():
+ free_mem, total_mem = torch.cuda.mem_get_info()
+ print(f"{prefix}: {(total_mem - free_mem) / (1024**3):.2f} GB / {total_mem / (1024**3):.2f} GB.")
+
+
+def _get_model_size(model: nn.Module, scale: str = "auto") -> Tuple[float, str]:
+ """Compute the model size."""
+ n_params = sum(p.numel() for p in model.parameters())
+
+ if scale == "auto":
+ if n_params > 1e9:
+ scale = "B"
+ elif n_params > 1e6:
+ scale = "M"
+ elif n_params > 1e3:
+ scale = "K"
+ else:
+ scale = ""
+
+ if scale == "B":
+ n_params = n_params / 1e9
+ elif scale == "M":
+ n_params = n_params / 1e6
+ elif scale == "K":
+ n_params = n_params / 1e3
+ elif scale == "":
+ pass
+ else:
+ raise NotImplementedError(f"Unknown scale {scale}.")
+
+ return n_params, scale
+
+
+def print_model_size(model: nn.Module, name: Optional[str] = None) -> None:
+ """Print the model size."""
+ if is_rank0():
+ n_params, scale = _get_model_size(model, scale="auto")
+ if name is None:
+ name = model.__class__.__name__
+
+ print(f"{name} contains {n_params:.2f}{scale} parameters.")
diff --git a/code/verl/utils/multimodal_contract.py b/code/verl/utils/multimodal_contract.py
new file mode 100644
index 0000000000000000000000000000000000000000..6be578a10de503a0b0071ec5bf39e628e1b77030
--- /dev/null
+++ b/code/verl/utils/multimodal_contract.py
@@ -0,0 +1,504 @@
+from __future__ import annotations
+
+import inspect
+import os
+from typing import Any, Optional, Sequence, Union
+
+import torch
+from PIL.Image import Image as ImageObject
+from qwen_vl_utils.vision_process import fetch_video
+
+
+def _maybe_patch_decord_num_threads() -> None:
+ """Monkey-patch ``decord.VideoReader`` so it defaults to multi-threaded
+ decoding when ``QWENVL_VIDEO_DECORD_NUM_THREADS`` is set.
+
+ decord exposes ``VideoReader(path, num_threads=N)`` for ffmpeg-internal
+ threading, but qwen_vl_utils.vision_process._read_video_decord always
+ constructs ``decord.VideoReader(video_path)`` with the default 1 thread.
+ Patching the constructor wrapper lets every caller (training dataset,
+ vLLM rollout, FSDP worker, offline preprocess) opt in via env var without
+ forking qwen_vl_utils.
+
+ Idempotent and safe to call multiple times. Set the env var to <=0 to
+ keep the upstream single-thread default.
+ """
+ try:
+ n_threads = int(os.environ.get("QWENVL_VIDEO_DECORD_NUM_THREADS", "0"))
+ except ValueError:
+ n_threads = 0
+ if n_threads <= 0:
+ return
+ try:
+ import decord
+ except ImportError:
+ return
+ if getattr(decord.VideoReader, "_orarl_threads_patched", False):
+ return
+ original_videoreader = decord.VideoReader
+
+ def threaded_videoreader(*args: Any, **kwargs: Any):
+ kwargs.setdefault("num_threads", n_threads)
+ return original_videoreader(*args, **kwargs)
+
+ threaded_videoreader._orarl_threads_patched = True # type: ignore[attr-defined]
+ threaded_videoreader._orarl_original = original_videoreader # type: ignore[attr-defined]
+ decord.VideoReader = threaded_videoreader
+
+
+_maybe_patch_decord_num_threads()
+
+
+VIDEO_SOURCE_MODE_PREFER_PREPROCESSED = "prefer_preprocessed"
+VIDEO_SOURCE_MODE_PREPROCESSED_ONLY = "preprocessed_only"
+VIDEO_SOURCE_MODE_REALTIME_ONLY = "realtime_only"
+
+VIDEO_SOURCE_TYPE_PREPROCESSED = "preprocessed"
+VIDEO_SOURCE_TYPE_PATH = "path"
+VIDEO_SOURCE_TYPE_INLINE = "inline"
+
+VALID_VIDEO_SOURCE_MODES = {
+ VIDEO_SOURCE_MODE_PREFER_PREPROCESSED,
+ VIDEO_SOURCE_MODE_PREPROCESSED_ONLY,
+ VIDEO_SOURCE_MODE_REALTIME_ONLY,
+}
+
+VALID_VIDEO_SOURCE_TYPES = {
+ VIDEO_SOURCE_TYPE_PREPROCESSED,
+ VIDEO_SOURCE_TYPE_PATH,
+ VIDEO_SOURCE_TYPE_INLINE,
+}
+
+try:
+ _FETCH_VIDEO_PARAM_NAMES = frozenset(inspect.signature(fetch_video).parameters)
+except (TypeError, ValueError):
+ _FETCH_VIDEO_PARAM_NAMES = frozenset()
+
+
+def _build_fallback_video_metadata(
+ video_data: Union[torch.Tensor, list[ImageObject]],
+ sample_fps: float,
+) -> dict[str, Any]:
+ if isinstance(video_data, torch.Tensor):
+ total_num_frames = int(video_data.shape[0])
+ height = int(video_data.shape[-2]) if video_data.ndim >= 3 else None
+ width = int(video_data.shape[-1]) if video_data.ndim >= 3 else None
+ else:
+ total_num_frames = len(video_data)
+ height = width = None
+ if total_num_frames > 0:
+ first_frame = video_data[0]
+ if isinstance(first_frame, ImageObject):
+ width, height = first_frame.size
+ elif isinstance(first_frame, torch.Tensor) and first_frame.ndim >= 2:
+ height = int(first_frame.shape[-2])
+ width = int(first_frame.shape[-1])
+
+ metadata = {
+ "fps": float(sample_fps),
+ "frames_indices": list(range(total_num_frames)),
+ "total_num_frames": total_num_frames,
+ }
+ if sample_fps > 0:
+ metadata["duration"] = total_num_frames / sample_fps
+ if width is not None and height is not None:
+ metadata["width"] = width
+ metadata["height"] = height
+ return metadata
+
+
+def process_video(
+ video: str,
+ min_pixels: int = 4 * 32 * 32,
+ max_pixels: int = 64 * 32 * 32,
+ max_frames: int = 128,
+ video_fps: float = 2.0,
+ total_pixels: Optional[int] = None,
+ return_fps: bool = False,
+) -> Any:
+ vision_info = {
+ "video": video,
+ "min_pixels": min_pixels,
+ "max_pixels": max_pixels,
+ "max_frames": max_frames,
+ "fps": video_fps,
+ }
+ if total_pixels is not None:
+ vision_info["total_pixels"] = total_pixels
+
+ fetch_kwargs = {}
+ if "image_patch_size" in _FETCH_VIDEO_PARAM_NAMES:
+ fetch_kwargs["image_patch_size"] = 16
+ elif "image_factor" in _FETCH_VIDEO_PARAM_NAMES:
+ fetch_kwargs["image_factor"] = 16
+ if return_fps and "return_video_sample_fps" in _FETCH_VIDEO_PARAM_NAMES:
+ fetch_kwargs["return_video_sample_fps"] = True
+ if return_fps and "return_video_metadata" in _FETCH_VIDEO_PARAM_NAMES:
+ fetch_kwargs["return_video_metadata"] = True
+
+ result = fetch_video(vision_info, **fetch_kwargs)
+ if not return_fps:
+ return result
+
+ if isinstance(result, tuple) and len(result) == 2:
+ video_data, sample_fps = result
+ else:
+ video_data, sample_fps = result, video_fps
+
+ sample_fps = float(sample_fps)
+ if isinstance(video_data, tuple) and len(video_data) == 2:
+ return video_data, sample_fps
+
+ return (video_data, _build_fallback_video_metadata(video_data, sample_fps)), sample_fps
+
+
+def normalize_video_source_mode(mode: Optional[str], *, use_preprocessed_videos: bool) -> str:
+ if mode is None or mode == "":
+ return (
+ VIDEO_SOURCE_MODE_PREFER_PREPROCESSED
+ if use_preprocessed_videos
+ else VIDEO_SOURCE_MODE_REALTIME_ONLY
+ )
+
+ if mode not in VALID_VIDEO_SOURCE_MODES:
+ raise ValueError(
+ f"Unsupported data.video_source_mode={mode!r}. "
+ f"Expected one of {sorted(VALID_VIDEO_SOURCE_MODES)}."
+ )
+
+ return mode
+
+
+def build_video_multimodal_contract(
+ *,
+ video_paths: Sequence[str],
+ preprocessed_video_path: Optional[str],
+ video_source_mode: str,
+ inline_frames: Optional[Sequence[Any]] = None,
+ inline_metadatas: Optional[Sequence[dict[str, Any]]] = None,
+) -> dict[str, Any]:
+ """Build the multi_modal_data['video'] contract for a single sample.
+
+ If ``inline_frames`` and ``inline_metadatas`` are provided, the contract
+ carries the decoded tensors directly so downstream consumers (vLLM rollout,
+ FSDP worker forward) can reuse them without re-decoding the mp4 / re-loading
+ the .pt artifact. The original ``video_paths`` are still recorded under
+ ``paths`` for traceability/debugging.
+ """
+ if not video_paths:
+ raise ValueError("video_paths must not be empty when building a video contract.")
+
+ if inline_frames is not None and inline_metadatas is not None:
+ if len(inline_frames) != len(inline_metadatas):
+ raise ValueError(
+ f"inline_frames ({len(inline_frames)}) and inline_metadatas "
+ f"({len(inline_metadatas)}) length mismatch."
+ )
+ if len(inline_frames) == 0:
+ raise ValueError("inline_frames must be non-empty when provided.")
+ return {
+ "video": {
+ "source_type": VIDEO_SOURCE_TYPE_INLINE,
+ "paths": list(video_paths),
+ "frames": list(inline_frames),
+ "metadatas": list(inline_metadatas),
+ }
+ }
+
+ if video_source_mode == VIDEO_SOURCE_MODE_PREPROCESSED_ONLY:
+ if preprocessed_video_path is None:
+ raise FileNotFoundError("video_source_mode=preprocessed_only but no preprocessed video is available.")
+ source_type = VIDEO_SOURCE_TYPE_PREPROCESSED
+ source_paths = [preprocessed_video_path]
+ elif video_source_mode == VIDEO_SOURCE_MODE_PREFER_PREPROCESSED and preprocessed_video_path is not None:
+ source_type = VIDEO_SOURCE_TYPE_PREPROCESSED
+ source_paths = [preprocessed_video_path]
+ elif video_source_mode in {
+ VIDEO_SOURCE_MODE_PREFER_PREPROCESSED,
+ VIDEO_SOURCE_MODE_REALTIME_ONLY,
+ }:
+ source_type = VIDEO_SOURCE_TYPE_PATH
+ source_paths = list(video_paths)
+ else:
+ raise ValueError(
+ f"Unsupported video_source_mode={video_source_mode!r}. "
+ f"Expected one of {sorted(VALID_VIDEO_SOURCE_MODES)}."
+ )
+
+ return {
+ "video": {
+ "source_type": source_type,
+ "paths": source_paths,
+ }
+ }
+
+
+def has_video_multimodal_data(multi_modal_data: dict[str, Any]) -> bool:
+ return any(
+ key in multi_modal_data
+ for key in ("video", "videos", "preprocessed_video_path")
+ )
+
+
+def validate_multi_modal_data_contract(multi_modal_data: dict[str, Any]) -> None:
+ if not isinstance(multi_modal_data, dict):
+ raise TypeError(f"multi_modal_data must be a dict, got {type(multi_modal_data).__name__}.")
+
+ if not multi_modal_data:
+ return
+
+ has_images = "images" in multi_modal_data
+ has_video = has_video_multimodal_data(multi_modal_data)
+ if has_images and has_video:
+ raise ValueError("A single sample cannot currently contain both images and video in multi_modal_data.")
+
+ if has_images:
+ images = multi_modal_data["images"]
+ if not hasattr(images, "__len__") or len(images) == 0:
+ raise ValueError("multi_modal_data['images'] must be a non-empty sequence.")
+ return
+
+ if "video" in multi_modal_data:
+ video_value = multi_modal_data["video"]
+ if isinstance(video_value, dict) and "source_type" in video_value:
+ source_type = video_value.get("source_type")
+ paths = video_value.get("paths")
+ if source_type not in VALID_VIDEO_SOURCE_TYPES:
+ raise ValueError(
+ f"multi_modal_data['video']['source_type']={source_type!r} is invalid. "
+ f"Expected one of {sorted(VALID_VIDEO_SOURCE_TYPES)}."
+ )
+ if not isinstance(paths, list) or len(paths) == 0 or not all(isinstance(path, str) for path in paths):
+ raise ValueError("multi_modal_data['video']['paths'] must be a non-empty list of strings.")
+ if source_type == VIDEO_SOURCE_TYPE_INLINE:
+ frames = video_value.get("frames")
+ metadatas = video_value.get("metadatas")
+ if not isinstance(frames, list) or len(frames) == 0:
+ raise ValueError("inline video contract must carry a non-empty 'frames' list.")
+ if not isinstance(metadatas, list) or len(metadatas) != len(frames):
+ raise ValueError("inline video contract 'metadatas' length must match 'frames'.")
+ return
+ return
+
+ if "videos" in multi_modal_data:
+ videos = multi_modal_data["videos"]
+ if not isinstance(videos, list) or len(videos) == 0 or not all(isinstance(path, str) for path in videos):
+ raise ValueError("Legacy multi_modal_data['videos'] must be a non-empty list of strings.")
+ return
+
+ if "preprocessed_video_path" in multi_modal_data:
+ path = multi_modal_data["preprocessed_video_path"]
+ if not isinstance(path, str) or path == "":
+ raise ValueError("Legacy multi_modal_data['preprocessed_video_path'] must be a non-empty string.")
+ return
+
+ raise ValueError(f"Unsupported multi_modal_data keys: {sorted(multi_modal_data.keys())}.")
+
+
+def _normalize_video_metadata(metadata: Any, frames: Any) -> dict[str, Any]:
+ if isinstance(metadata, dict):
+ normalized = dict(metadata)
+ elif hasattr(metadata, "keys") and hasattr(metadata, "__getitem__"):
+ normalized = {key: metadata[key] for key in metadata.keys()}
+ else:
+ total_num_frames = frames.shape[0] if hasattr(frames, "shape") else len(frames)
+ normalized = {
+ "fps": 2.0,
+ "frames_indices": list(range(total_num_frames)),
+ "total_num_frames": total_num_frames,
+ }
+
+ # Some cache producers persist ``sample_fps`` as an internal convenience
+ # field. Transformers constructs VideoMetadata with ``**metadata`` and does
+ # not accept that key. Preserve its value as fps only when fps is absent,
+ # then remove the cache-only field before the processor sees it.
+ sample_fps = normalized.pop("sample_fps", None)
+ if normalized.get("fps") is None and sample_fps is not None:
+ normalized["fps"] = sample_fps
+ return normalized
+
+
+def video_metadata_for_model(metadata: Any, frames: Any) -> dict[str, Any]:
+ """Return the temporal metadata passed to Qwen3-VL.
+
+ The upstream contract preserves qwen-vl-utils metadata: ``fps`` is the
+ original video FPS and ``frames_indices`` refer to original frames. Qwen3-VL
+ uses those values to render the textual ```` tokens.
+
+ Earlier runtime versions rewrote metadata to describe the sampled tensor in
+ order to avoid vLLM/FSDP feature-count mismatches. That stays the default;
+ parity runs opt into the upstream contract explicitly.
+ """
+ if os.environ.get("VERL_PRESERVE_VIDEO_METADATA", "0") == "1":
+ return _normalize_video_metadata(metadata, frames)
+ return self_describe_video_metadata(metadata, frames)
+
+
+def self_describe_video_metadata(metadata: Any, frames: Any) -> dict[str, Any]:
+ """Rewrite metadata so it self-describes the *decoded* frame tensor.
+
+ The vLLM rollout can re-derive the number of video frames from
+ ``total_num_frames`` / ``fps`` in the metadata. When those fields describe
+ the ORIGINAL clip rather than the frames actually present in ``frames``
+ (which is what ``qwen_vl_utils.fetch_video`` returns after sampling), the
+ rollout expands a different number of ``<|video_pad|>`` tokens than the
+ ``do_sample_frames=False`` FSDP forward, raising
+
+ ValueError: Video features and video tokens do not match
+
+ This returns metadata that is internally consistent with ``frames`` while
+ preserving the real-time span of the clip, so uniform-sampling timestamps
+ (``frames_indices / fps``) stay correct for temporal grounding.
+ """
+ base = _normalize_video_metadata(metadata, frames)
+ num_frames = int(frames.shape[0] if hasattr(frames, "shape") else len(frames))
+ if num_frames <= 0:
+ return base
+
+ raw_fps = base.get("fps")
+ raw_total = base.get("total_num_frames")
+ raw_indices = base.get("frames_indices")
+ fps_valid = isinstance(raw_fps, (int, float)) and raw_fps > 0
+
+ # Recover the real clip duration (seconds) to preserve grounding timestamps.
+ # Prefer an explicit duration, then original_total_frames/original_fps (the most
+ # reliable), then the frames_indices span, then a last-resort fallback.
+ duration = base.get("duration")
+ if not isinstance(duration, (int, float)) or duration <= 0:
+ if fps_valid and isinstance(raw_total, (int, float)) and raw_total > 0:
+ duration = float(raw_total) / float(raw_fps)
+ elif fps_valid and hasattr(raw_indices, "__len__") and len(raw_indices) > 1:
+ duration = (float(raw_indices[-1]) - float(raw_indices[0]) + 1.0) / float(raw_fps)
+ elif fps_valid:
+ duration = num_frames / float(raw_fps)
+ else:
+ duration = float(num_frames)
+
+ sample_fps = num_frames / duration if duration > 0 else (float(raw_fps) if fps_valid else 2.0)
+ normalized: dict[str, Any] = {
+ "fps": float(sample_fps),
+ "frames_indices": list(range(num_frames)),
+ "total_num_frames": num_frames,
+ "duration": float(duration),
+ }
+ for key in ("width", "height", "video_backend"):
+ if key in base and base[key] is not None:
+ normalized[key] = base[key]
+ return normalized
+
+
+def _load_preprocessed_video_artifact(path: str) -> tuple[Any, dict[str, Any]]:
+ if not os.path.exists(path):
+ raise FileNotFoundError(f"Preprocessed video artifact not found: {path}")
+
+ preprocessed_data = torch.load(path, map_location="cpu", weights_only=False)
+ if "frames" not in preprocessed_data or "metadata" not in preprocessed_data:
+ raise KeyError(
+ f"Preprocessed video artifact {path} must contain 'frames' and 'metadata'. "
+ f"Found keys: {sorted(preprocessed_data.keys())}."
+ )
+
+ return preprocessed_data["frames"], _normalize_video_metadata(
+ preprocessed_data["metadata"],
+ preprocessed_data["frames"],
+ )
+
+
+def load_video_tensors_and_metadata(
+ multi_modal_data: dict[str, Any],
+ *,
+ video_min_pixels: int,
+ video_max_pixels: int,
+ video_max_frames: int,
+ video_fps: float,
+ video_total_pixels: Optional[int],
+) -> tuple[list[Any], Optional[list[dict[str, Any]]]]:
+ if not multi_modal_data:
+ return [], None
+
+ if "video" in multi_modal_data:
+ video_value = multi_modal_data["video"]
+ if isinstance(video_value, dict) and "source_type" in video_value:
+ source_type = video_value["source_type"]
+ source_paths = video_value["paths"]
+ videos: list[Any] = []
+ video_metadatas: list[dict[str, Any]] = []
+
+ if source_type == VIDEO_SOURCE_TYPE_INLINE:
+ inline_frames = video_value.get("frames")
+ inline_metadatas = video_value.get("metadatas")
+ if inline_frames is None or inline_metadatas is None:
+ raise ValueError(
+ "multi_modal_data['video']['source_type']='inline' but "
+ "'frames'/'metadatas' are missing."
+ )
+ for frames, metadata in zip(inline_frames, inline_metadatas):
+ videos.append(frames)
+ video_metadatas.append(video_metadata_for_model(metadata, frames))
+ return videos, video_metadatas
+
+ if source_type == VIDEO_SOURCE_TYPE_PREPROCESSED:
+ for path in source_paths:
+ frames, metadata = _load_preprocessed_video_artifact(path)
+ videos.append(frames)
+ video_metadatas.append(metadata)
+ return videos, video_metadatas
+
+ if source_type == VIDEO_SOURCE_TYPE_PATH:
+ for path in source_paths:
+ result = process_video(
+ path,
+ min_pixels=video_min_pixels,
+ max_pixels=video_max_pixels,
+ max_frames=video_max_frames,
+ video_fps=video_fps,
+ total_pixels=video_total_pixels,
+ return_fps=True,
+ )
+ video_data, _ = result
+ if isinstance(video_data, tuple) and len(video_data) == 2:
+ frames, metadata = video_data
+ else:
+ frames = video_data
+ metadata = None
+ videos.append(frames)
+ video_metadatas.append(video_metadata_for_model(metadata, frames))
+ return videos, video_metadatas
+
+ raise ValueError(
+ f"Unsupported multi_modal_data['video']['source_type']={source_type!r}. "
+ f"Expected one of {sorted(VALID_VIDEO_SOURCE_TYPES)}."
+ )
+
+ videos = multi_modal_data["video"]
+ video_metadatas = multi_modal_data.get("video_metadatas", None)
+ return videos, video_metadatas
+
+ if "preprocessed_video_path" in multi_modal_data:
+ frames, metadata = _load_preprocessed_video_artifact(multi_modal_data["preprocessed_video_path"])
+ return [frames], [metadata]
+
+ if "videos" in multi_modal_data:
+ videos = []
+ video_metadatas = []
+ for path in multi_modal_data["videos"]:
+ result = process_video(
+ path,
+ min_pixels=video_min_pixels,
+ max_pixels=video_max_pixels,
+ max_frames=video_max_frames,
+ video_fps=video_fps,
+ total_pixels=video_total_pixels,
+ return_fps=True,
+ )
+ video_data, _ = result
+ if isinstance(video_data, tuple) and len(video_data) == 2:
+ frames, metadata = video_data
+ else:
+ frames = video_data
+ metadata = None
+ videos.append(frames)
+ video_metadatas.append(video_metadata_for_model(metadata, frames))
+ return videos, video_metadatas
+
+ return [], None
diff --git a/code/verl/utils/prompt_template.py b/code/verl/utils/prompt_template.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d347c6db0e3a5487e1db2fc3fae3099bd365312
--- /dev/null
+++ b/code/verl/utils/prompt_template.py
@@ -0,0 +1,193 @@
+# Copyright 2024 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 __future__ import annotations
+
+from typing import Any
+
+
+QUESTION_TEMPLATE = (
+ "{Question}\n"
+ "Please answer this question based on the visual content."
+ "Provide your thinking process between the and tags, and then give your final answer between the and tags."
+ "At the end, you must output the final answer in the format:\n"
+ " \n"
+)
+
+# NOTE: tag is NOT required. Only tag is mandatory.
+# This allows both Instruct and Thinking models to work without format penalty.
+# QUESTION_TEMPLATE = (
+# "{Question}\n"
+# "Please answer this question based on the visual content. "
+# "Provide your final answer within the ... tags.\n"
+# )
+
+TYPE_TEMPLATE = {
+ "multiple choice": (
+ "Please provide only the single option letter (e.g., A, B, C, D, etc.) "
+ "within the ... tags.\n"
+ "Example:\nA "
+ ),
+ "numerical": (
+ "Please provide only the numerical value within the ... tags.\n"
+ "Example:\n3.14 "
+ ),
+ "OCR": (
+ "Please provide only the transcribed text within the ... tags.\n"
+ "Example:\nHello World "
+ ),
+ "ocr": (
+ "Please provide only the transcribed text within the ... tags.\n"
+ "Example:\nHello World "
+ ),
+ "open-ended": (
+ "Please provide only your text answer within the ... tags.\n"
+ "Example:\nThe capital of France is Paris. "
+ ),
+ "regression": (
+ "Please provide only the numerical value within the ... tags.\n"
+ "Example:\n42.7 "
+ ),
+ "math": (
+ "Please provide only the final answer within the ... tags.\n"
+ "For multiple choice questions, provide the option letter (e.g., A, B, C, D).\n"
+ "For calculation problems, provide the numerical result or LaTeX formula.\n"
+ "Examples:\n"
+ "B \n"
+ "42 \n"
+ "$$-\\dfrac{3}{2}$$ "
+ ),
+ "temporal grounding": (
+ "Please provide only the time span in seconds as JSON within the ... tags.\n"
+ 'Example:\n{"time": [12.3, 25.7]} '
+ ),
+ "spatial grounding": (
+ "Please provide only the bounding box as JSON with key 'boxes' within the ... tags.\n"
+ 'Example:\n{"boxes": [35, 227, 437, 932]} '
+ ),
+ "spatial-temporal grounding": (
+ "Please provide only the time span in seconds and bounding boxes as JSON within the ... tags.\n"
+ "You MUST output one bounding box for every integer second within the given time span (inclusive).\n"
+ "Example:\n"
+ '{"time": [8.125, 13.483], "boxes": {"9": [317, 422, 582, 997], '
+ '"10": [332, 175, 442, 369], "11": [340, 180, 450, 370]}} \n'
+ "Note: Each key in 'boxes' must be an integer second within the span, and its value must be a 4-number bounding box [x1, y1, x2, y2]."
+ ),
+ "tracking": (
+ "Please track the target object throughout the video and provide one bounding box per second, "
+ "ONLY up to 32 seconds, within the ... tags.\n"
+ "Example:\n"
+ '{"boxes": {"1": [405, 230, 654, 463], "2": [435, 223, 678, 446], ..., '
+ '"32": [415, 203, 691, 487]}} \n'
+ "Note: Each key in 'boxes' must correspond to a second (1, 2, 3, ..., 32) and contain a 4-number bounding box [x1, y1, x2, y2]."
+ ),
+ "segmentation_image": (
+ "This task prepares inputs for image object segmentation with a specialized model (e.g., SAM2).\n"
+ "Please provide ONE bounding box, 3 positive points (clearly INSIDE the object), and 3 negative points "
+ "(clearly OUTSIDE the object) within the ... tags.\n"
+ "Choose informative points that help distinguish object vs. background. Prefer negatives on clear non-object "
+ "pixels INSIDE the box when safe; otherwise place them just outside on obvious background. "
+ "Negatives must NEVER be on the object or on its boundary.\n"
+ "Example:\n"
+ '{"boxes": [x1, y1, x2, y2], "positive_points": [[x,y],[x,y],[x,y]], '
+ '"negative_points": [[x,y],[x,y],[x,y]]} '
+ ),
+ "segmentation_video": (
+ "This task prepares inputs for video object segmentation with a specialized model (e.g., SAM2).\n"
+ "Please select ONE representative time (in seconds), and provide ONE bounding box, "
+ "3 positive points (clearly INSIDE the object), and 3 negative points (clearly OUTSIDE the object) "
+ "within the ... tags.\n"
+ "Choose informative points that help distinguish object vs. background. Prefer negatives on clear non-object "
+ "pixels INSIDE the box when safe; otherwise place them just outside on obvious background. "
+ "Negatives must NEVER be on the object or on its boundary.\n"
+ "Example:\n"
+ '{"time": , "boxes": [x1, y1, x2, y2], '
+ '"positive_points": [[x,y],[x,y],[x,y]], "negative_points": [[x,y],[x,y],[x,y]]} '
+ ),
+ # ===== Additional task types =====
+ "code": (
+ "Please provide only the complete Python code within the ... tags.\n"
+ "Make sure your code is properly formatted and includes all necessary imports.\n"
+ "Example:\n"
+ "\n"
+ "def solve(lst):\n"
+ " # Your implementation\n"
+ " return result\n"
+ " "
+ ),
+ "svg-code": (
+ "Please provide only the complete SVG code within the ... tags.\n"
+ "Example:\n"
+ "\n"
+ '\n'
+ ' \n'
+ " \n"
+ " "
+ ),
+ "html-code": (
+ "Please provide only the complete HTML code within the ... tags.\n"
+ "Example:\n"
+ "\n"
+ "\n"
+ "\n"
+ "Page \n"
+ "Hello \n"
+ "\n"
+ " "
+ ),
+ "boolean": (
+ "Please provide only 'Yes' or 'No' within the ... tags.\nExample:\nYes "
+ ),
+ "binary classification": (
+ "Please provide only 'Yes' or 'No' within the ... tags.\nExample:\nYes "
+ ),
+ "llava": (
+ "Please compare the two responses and determine which one is better.\n"
+ "Provide your answer as one of: 'Response 1', 'Response 2', or 'Tie' "
+ "within the ... tags.\n"
+ "Example:\nResponse 1 "
+ ),
+ "video qa": (
+ "Please provide only your text answer based on the video content "
+ "within the ... tags.\n"
+ "Example:\nThe person is playing basketball. "
+ ),
+ "video description": (
+ "Please provide a detailed description of what you see in the video "
+ "within the ... tags.\n"
+ "Example:\nA man is walking down the street carrying a bag. "
+ ),
+ "free-form": (
+ "Please provide your answer within the ... tags.\n"
+ "Example:\nYour answer here. "
+ ),
+}
+
+
+def build_prompt(prompt_str: str, example: dict[str, Any]) -> str:
+ data_type = (example.get("data_type") or "").strip().lower()
+ problem_type = example.get("problem_type") or ""
+ question = prompt_str
+
+ if problem_type == "multiple choice" and isinstance(example.get("options"), list) and example["options"]:
+ opts = "\n".join(example["options"])
+ question = f"{question}\nOptions:\n{opts}"
+
+ if problem_type == "segmentation":
+ type_key = "segmentation_video" if data_type == "video" else "segmentation_image"
+ else:
+ type_key = problem_type
+
+ tail = TYPE_TEMPLATE.get(type_key, "")
+ return QUESTION_TEMPLATE.format(Question=question) + tail
diff --git a/code/verl/utils/py_functional.py b/code/verl/utils/py_functional.py
new file mode 100644
index 0000000000000000000000000000000000000000..20159dee1067fb9e10fa61d801b8ef80fdf32ee0
--- /dev/null
+++ b/code/verl/utils/py_functional.py
@@ -0,0 +1,136 @@
+# Copyright 2024 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.
+"""
+Contain small python utility functions
+"""
+
+import importlib.metadata
+import importlib.util
+import os
+import re
+from contextlib import contextmanager
+from functools import lru_cache
+from typing import Any, Optional, Union
+
+import numpy as np
+import yaml
+from codetiming import Timer
+from packaging import version
+from yaml import Dumper
+
+
+def is_sci_notation(number: float) -> bool:
+ pattern = re.compile(r"^[+-]?\d+(\.\d*)?[eE][+-]?\d+$")
+ return bool(pattern.match(str(number)))
+
+
+def float_representer(dumper: Dumper, number: Union[float, np.float32, np.float64]):
+ if is_sci_notation(number):
+ value = str(number)
+ if "." not in value and "e" in value:
+ value = value.replace("e", ".0e", 1)
+ else:
+ value = str(round(number, 3))
+
+ return dumper.represent_scalar("tag:yaml.org,2002:float", value)
+
+
+yaml.add_representer(float, float_representer)
+yaml.add_representer(np.float32, float_representer)
+yaml.add_representer(np.float64, float_representer)
+
+
+@lru_cache
+def is_package_available(name: str) -> bool:
+ return importlib.util.find_spec(name) is not None
+
+
+def get_package_version(name: str) -> "version.Version":
+ try:
+ return version.parse(importlib.metadata.version(name))
+ except Exception:
+ return version.parse("0.0.0")
+
+
+@lru_cache
+def is_transformers_version_greater_than(content: str):
+ return get_package_version("transformers") >= version.parse(content)
+
+
+def union_two_dict(dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, Any]:
+ """Union two dict. Will throw an error if there is an item not the same object with the same key."""
+ for key in dict2.keys():
+ if key in dict1:
+ assert dict1[key] == dict2[key], f"{key} in dict1 and dict2 are not the same object"
+
+ dict1[key] = dict2[key]
+
+ return dict1
+
+
+def append_to_dict(data: dict[str, list[Any]], new_data: dict[str, Any]) -> None:
+ """Append dict to a dict of list."""
+ for key, val in new_data.items():
+ if key not in data:
+ data[key] = []
+
+ data[key].append(val)
+
+
+def unflatten_dict(data: dict[str, Any], sep: str = "/") -> dict[str, Any]:
+ unflattened = {}
+ for key, value in data.items():
+ pieces = key.split(sep)
+ pointer = unflattened
+ for piece in pieces[:-1]:
+ if piece not in pointer:
+ pointer[piece] = {}
+
+ pointer = pointer[piece]
+
+ pointer[pieces[-1]] = value
+
+ return unflattened
+
+
+def flatten_dict(data: dict[str, Any], parent_key: str = "", sep: str = "/") -> dict[str, Any]:
+ flattened = {}
+ for key, value in data.items():
+ new_key = parent_key + sep + key if parent_key else key
+ if isinstance(value, dict):
+ flattened.update(flatten_dict(value, new_key, sep=sep))
+ else:
+ flattened[new_key] = value
+
+ return flattened
+
+
+def convert_dict_to_str(data: dict[str, Any]) -> str:
+ return yaml.dump(data, indent=2)
+
+
+def get_abs_path(path: str, prompt: str = "File") -> Optional[str]:
+ if path is not None:
+ if os.path.exists(path): # ray job uses absolute path
+ return os.path.abspath(path)
+ else:
+ print(f"{prompt} {path} not found.")
+
+
+@contextmanager
+def timer(name: str, timing_raw: dict[str, float]):
+ with Timer(name=name, logger=None) as timer:
+ yield
+
+ timing_raw[name] = timer.last
diff --git a/code/verl/utils/seqlen_balancing.py b/code/verl/utils/seqlen_balancing.py
new file mode 100644
index 0000000000000000000000000000000000000000..4cc268c2f942df9418c1ed9dab184f8c93eb6690
--- /dev/null
+++ b/code/verl/utils/seqlen_balancing.py
@@ -0,0 +1,330 @@
+# Copyright 2024 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.
+
+import copy
+import heapq
+from itertools import chain
+from typing import Optional, Tuple
+
+import torch
+from tensordict import TensorDict
+from torch import distributed as dist
+
+from ..protocol import DataProto
+
+
+class Set:
+ def __init__(self) -> None:
+ self.sum = 0
+ self.items = []
+
+ def add(self, idx: int, val: int):
+ self.items.append((idx, val))
+ self.sum += val
+
+ def merge(self, other):
+ for idx, val in other.items:
+ self.items.append((idx, val))
+ self.sum += val
+
+ def __lt__(self, other):
+ if self.sum != other.sum:
+ return self.sum < other.sum
+ if len(self.items) != len(other.items):
+ return len(self.items) < len(other.items)
+ return self.items < other.items
+
+
+class State:
+ def __init__(self, items: list[Tuple[int, int]], k: int) -> None:
+ self.k = k
+ # sets should always be decreasing order
+ self.sets = [Set() for _ in range(k)]
+ assert len(items) in [1, k], f"{len(items)} not in [1, {k}]"
+ for i, (idx, seqlen) in enumerate(items):
+ self.sets[i].add(idx=idx, val=seqlen)
+ self.sets = sorted(self.sets, reverse=True)
+
+ def get_partitions(self):
+ partitions = []
+ for i in range(len(self.sets)):
+ cur_partition = []
+ for idx, _ in self.sets[i].items:
+ cur_partition.append(idx)
+ partitions.append(cur_partition)
+ return partitions
+
+ def merge(self, other):
+ for i in range(self.k):
+ self.sets[i].merge(other.sets[self.k - 1 - i])
+ self.sets = sorted(self.sets, reverse=True)
+
+ @property
+ def spread(self) -> int:
+ return self.sets[0].sum - self.sets[-1].sum
+
+ def __lt__(self, other):
+ # least heap, let the state with largest spread to be popped first,
+ # if the spread is the same, let the state who has the largest set
+ # to be popped first.
+ if self.spread != other.spread:
+ return self.spread > other.spread
+ return self.sets[0] > other.sets[0]
+
+ def __repr__(self) -> str:
+ repr_str = "["
+ for i in range(self.k):
+ if i > 0:
+ repr_str += ","
+ repr_str += "{"
+ for j, (_, seqlen) in enumerate(self.sets[i].items):
+ if j > 0:
+ repr_str += ","
+ repr_str += str(seqlen)
+ repr_str += "}"
+ repr_str += "]"
+ return repr_str
+
+
+def karmarkar_karp(seqlen_list: list[int], k_partitions: int, equal_size: bool):
+ # see: https://en.wikipedia.org/wiki/Largest_differencing_method
+ sorted_seqlen_list = sorted([(seqlen, i) for i, seqlen in enumerate(seqlen_list)])
+ states_pq: list[State] = []
+ if equal_size:
+ assert len(seqlen_list) % k_partitions == 0, f"{len(seqlen_list)} % {k_partitions} != 0"
+ for offset in range(0, len(sorted_seqlen_list), k_partitions):
+ items = []
+ for i in range(k_partitions):
+ seqlen, idx = sorted_seqlen_list[offset + i]
+ items.append((idx, seqlen))
+ heapq.heappush(states_pq, State(items=items, k=k_partitions))
+ else:
+ for seqlen, idx in sorted_seqlen_list:
+ heapq.heappush(states_pq, State(items=[(idx, seqlen)], k=k_partitions))
+
+ while len(states_pq) > 1:
+ state0 = heapq.heappop(states_pq)
+ state1 = heapq.heappop(states_pq)
+ # merge states
+ state0.merge(state1)
+ heapq.heappush(states_pq, state0)
+
+ final_state = states_pq[0]
+ partitions = final_state.get_partitions()
+ if equal_size:
+ for i, partition in enumerate(partitions):
+ assert len(partition) * k_partitions == len(seqlen_list), (
+ f"{len(partition)} * {k_partitions} != {len(seqlen_list)}"
+ )
+ return partitions
+
+
+def greedy_partition(seqlen_list: list[int], k_partitions: int, equal_size: bool):
+ bias = sum(seqlen_list) + 1 if equal_size else 0
+ sorted_seqlen = [(seqlen + bias, i) for i, seqlen in enumerate(seqlen_list)]
+ partitions = [[] for _ in range(k_partitions)]
+ partition_sums = [0 for _ in range(k_partitions)]
+ for seqlen, i in sorted_seqlen:
+ min_idx = None
+ for j in range(k_partitions):
+ if min_idx is None or partition_sums[j] < partition_sums[min_idx]:
+ min_idx = j
+ partitions[min_idx].append(i)
+ partition_sums[min_idx] += seqlen
+ if equal_size:
+ for i, partition in enumerate(partitions):
+ assert len(partition) * k_partitions == len(seqlen_list), (
+ f"{len(partition)} * {k_partitions} != {len(seqlen_list)}"
+ )
+ return partitions
+
+
+def get_seqlen_balanced_partitions(seqlen_list: list[int], k_partitions: int, equal_size: bool) -> list[list[int]]:
+ """Get order of seq lengths to make partitions balanced, this is
+ used in balacing sum of seqlength across dp ranks and microbatches.
+
+ Parameters:
+ seqlen_list (List[int]):
+ seq lengths of each items
+ k_partitions (int):
+ resulting number of partitions
+ equal_size (bool):
+ if True, number of items in each partitions must be equal.
+ if False, only consider balancing the sum, each partition can have variable number of items
+
+ Returns:
+ partitions (List[List[int]]):
+ return k_partitions list containing the index of items.
+ """
+ assert len(seqlen_list) >= k_partitions, f"number of items:[{len(seqlen_list)}] < k_partitions:[{k_partitions}]"
+
+ def _check_and_sort_partitions(partitions):
+ assert len(partitions) == k_partitions, f"{len(partitions)} != {k_partitions}"
+ seen_idx = set()
+ sorted_partitions = [None] * k_partitions
+ for i, partition in enumerate(partitions):
+ assert len(partition) > 0, f"the {i}-th partition is empty"
+ for idx in partition:
+ seen_idx.add(idx)
+ sorted_partitions[i] = sorted(partition)
+ assert seen_idx == set(range(len(seqlen_list)))
+ return sorted_partitions
+
+ partitions = karmarkar_karp(seqlen_list=seqlen_list, k_partitions=k_partitions, equal_size=equal_size)
+ return _check_and_sort_partitions(partitions)
+
+
+def log_seqlen_unbalance(seqlen_list: list[int], partitions: list[list[int]], prefix: str) -> dict[str, float]:
+ """
+ Calculate and log metrics related to sequence length imbalance before and after partitioning.
+
+ Args:
+ seqlen_list (List[int]): A list of sequence lengths for each item.
+ partitions (List[List[int]]): A list of partitions, where each inner list contains indices
+ from seqlen_list assigned to that partition.
+ prefix (str): A prefix to be added to each metric key in the returned dictionary.
+
+ Returns:
+ dict: A dictionary containing metrics related to sequence length imbalance.
+ """
+ # Get the number of partitions
+ k_partition = len(partitions)
+ # assert len(seqlen_list) % k_partition == 0
+ batch_size = len(seqlen_list) // k_partition
+ min_sum_seqlen = None
+ max_sum_seqlen = None
+ total_sum_seqlen = 0
+
+ # Iterate over each batch of sequence lengths
+ for offset in range(0, len(seqlen_list), batch_size):
+ cur_sum_seqlen = sum(seqlen_list[offset : offset + batch_size])
+ if min_sum_seqlen is None or cur_sum_seqlen < min_sum_seqlen:
+ min_sum_seqlen = cur_sum_seqlen
+ if max_sum_seqlen is None or cur_sum_seqlen > max_sum_seqlen:
+ max_sum_seqlen = cur_sum_seqlen
+ total_sum_seqlen += cur_sum_seqlen
+
+ balanced_sum_seqlen_list = []
+ for partition in partitions:
+ cur_sum_seqlen_balanced = sum([seqlen_list[i] for i in partition])
+ balanced_sum_seqlen_list.append(cur_sum_seqlen_balanced)
+
+ min_sum_seqlen_balanced = min(balanced_sum_seqlen_list)
+ max_sum_seqlen_balanced = max(balanced_sum_seqlen_list)
+
+ return {
+ f"{prefix}/min": min_sum_seqlen,
+ f"{prefix}/max": max_sum_seqlen,
+ f"{prefix}/minmax_diff": max_sum_seqlen - min_sum_seqlen,
+ f"{prefix}/balanced_min": min_sum_seqlen_balanced,
+ f"{prefix}/balanced_max": max_sum_seqlen_balanced,
+ f"{prefix}/mean": total_sum_seqlen / len(partitions),
+ }
+
+
+def ceildiv(a: float, b: float) -> float:
+ return -(a // -b)
+
+
+def rearrange_micro_batches(
+ batch: TensorDict, max_token_len: int, dp_group: Optional[dist.ProcessGroup] = None
+) -> Tuple[list[TensorDict], list[list[int]]]:
+ """Split the batch into a list of micro_batches, where the max_token_len is smaller than max_token_len
+ and the number of valid tokens in each micro batch is well balanced.
+ """
+ # this is per local micro_bsz
+ max_seq_len = batch["attention_mask"].shape[-1]
+ assert max_token_len >= max_seq_len, (
+ f"max_token_len must be greater than the sequence length. Got {max_token_len=} and {max_seq_len=}"
+ )
+ effective_seqlen = torch.sum(batch["attention_mask"], dim=-1)
+ total_seqlen = effective_seqlen.sum().item()
+ num_micro_batches = min(len(effective_seqlen), ceildiv(total_seqlen, max_token_len))
+ if dist.is_initialized():
+ num_micro_batches = torch.tensor([num_micro_batches], device="cuda")
+ dist.all_reduce(num_micro_batches, op=dist.ReduceOp.MAX, group=dp_group)
+ num_micro_batches = num_micro_batches.cpu().item()
+
+ effective_seqlen = effective_seqlen.tolist()
+ assert num_micro_batches <= len(effective_seqlen)
+ micro_bsz_idx = get_seqlen_balanced_partitions(effective_seqlen, num_micro_batches, equal_size=False)
+
+ # Use the sum of squared sequence lengths to approximate attention computation workload
+ def compute_workload(partition: list[int]) -> Tuple[int, int]:
+ return (sum(effective_seqlen[idx] ** 2 for idx in partition), min(partition) if partition else 0)
+
+ micro_bsz_idx.sort(key=compute_workload, reverse=True)
+
+ micro_batches = []
+ for partition in micro_bsz_idx:
+ curr_micro_batch = [batch[idx] for idx in partition]
+ micro_batches.append(torch.stack(curr_micro_batch))
+
+ return micro_batches, micro_bsz_idx
+
+
+def get_reverse_idx(idx_map: list[int]) -> list[int]:
+ """
+ Build the inverse of an index mapping.
+
+ Args:
+ idx_map (Sequence[int]): Sequence where idx_map[i] = j.
+
+ Returns:
+ List[int]: Inverse mapping list such that output[j] = i for each i.
+ """
+ reverse_idx_map = copy.deepcopy(idx_map)
+
+ for i, idx in enumerate(idx_map):
+ reverse_idx_map[idx] = i
+
+ return reverse_idx_map
+
+
+def prepare_dynamic_batch(data: DataProto, max_token_len: int) -> tuple[list[DataProto], list[list[int]]]:
+ """
+ Prepare a batch for dynamic batching.
+
+ Args:
+ data (DataProto): The input data.
+ max_token_len (int): The maximum token length for dynamic batching.
+
+ Returns:
+ Tuple[List[DataProto], List[List[int]]]: A tuple containing a list of DataProto objects
+ and a list of index lists.
+ """
+ batch, batch_idx_list = rearrange_micro_batches(data.batch, max_token_len=max_token_len)
+ micro_batches = []
+ for i, batch_idx in enumerate(batch_idx_list):
+ tensors = dict(batch[i])
+ non_tensors = {key: value[batch_idx] for key, value in data.non_tensor_batch.items()}
+ micro_batches.append(DataProto.from_dict(tensors, non_tensors))
+
+ return micro_batches, batch_idx_list
+
+
+def restore_dynamic_batch(data: torch.Tensor, batch_idx_list: list[list[int]]) -> torch.Tensor:
+ """
+ Restore a batch from dynamic batching.
+
+ Args:
+ data (torch.Tensor): The input data.
+ batch_idx_list (List[List[int]]): The list of index lists.
+
+ Returns:
+ torch.Tensor: The restored data.
+ """
+ indices = list(chain.from_iterable(batch_idx_list))
+ revert_indices = torch.tensor(get_reverse_idx(indices), dtype=torch.long)
+ return data[revert_indices]
diff --git a/code/verl/utils/tokenizer.py b/code/verl/utils/tokenizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..86e0929488eb6c9a04627a05d87e0a53e8a01f7a
--- /dev/null
+++ b/code/verl/utils/tokenizer.py
@@ -0,0 +1,57 @@
+# Copyright 2024 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.
+"""Utils for tokenization."""
+
+from typing import Optional
+
+from transformers import AutoProcessor, AutoTokenizer, PreTrainedTokenizer, ProcessorMixin
+
+
+def get_tokenizer(model_path: str, override_chat_template: Optional[str] = None, **kwargs) -> PreTrainedTokenizer:
+ """Create a huggingface pretrained tokenizer."""
+ tokenizer = AutoTokenizer.from_pretrained(model_path, **kwargs)
+ if override_chat_template is not None:
+ with open(override_chat_template) as f:
+ tokenizer.chat_template = f.read()
+
+ print(f"New chat template: {tokenizer.chat_template}")
+
+ if tokenizer.bos_token == "" and tokenizer.eos_token == "":
+ # the EOS token in gemma2 & gemma3 is ambiguious, which may worsen RL performance.
+ # https://huggingface.co/google/gemma-2-2b-it/commit/17a01657f5c87135bcdd0ec7abb4b2dece04408a
+ print("Found gemma model. Set eos_token and eos_token_id to and 107.")
+ tokenizer.eos_token = ""
+
+ if tokenizer.pad_token_id is None:
+ print("Pad token is None. Set it to eos_token.")
+ tokenizer.pad_token = tokenizer.eos_token
+
+ return tokenizer
+
+
+def get_processor(model_path: str, override_chat_template: Optional[str] = None, **kwargs) -> Optional[ProcessorMixin]:
+ """Create a huggingface pretrained processor."""
+ processor = AutoProcessor.from_pretrained(model_path, **kwargs)
+ if override_chat_template is not None:
+ with open(override_chat_template) as f:
+ processor.chat_template = f.read()
+
+ print(f"New chat template: {processor.chat_template}")
+
+ # Avoid load tokenizer, see:
+ # https://github.com/huggingface/transformers/blob/v4.52.4/src/transformers/models/auto/processing_auto.py#L386
+ if processor is not None and "Processor" not in processor.__class__.__name__:
+ processor = None
+
+ return processor