diff --git a/.gitignore b/.gitignore index f82962c..9d0f4f5 100644 --- a/.gitignore +++ b/.gitignore @@ -80,8 +80,12 @@ nvflow/recipes/finance/datasets/finance_agent/*.jsonl nvflow/recipes/finance/datasets/finance_agent/*.csv nvflow/recipes/finance/datasets/finance_agent/*.json +# Backup files +*.bak + # OS .DS_Store +._* Thumbs.db # Jupyter diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 89951cb..5c0c12a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,10 @@ default: tags: - llm-mlops + interruptible: true + before_script: + - apt-get update -qq && apt-get install -y -qq git > /dev/null + - pip install -q uv stages: - lint @@ -10,14 +14,12 @@ stages: variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" - # Fetch full git history for hatch-vcs to determine version from tags - GIT_DEPTH: 0 + UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv" cache: key: ${CI_COMMIT_REF_SLUG} paths: - - .cache/pip/ - - .venv/ + - .cache/ # ============================================================================= # LINT STAGE @@ -25,19 +27,13 @@ cache: lint: stage: lint image: python:3.12-slim - before_script: - - apt-get update && apt-get install -y git - - pip install uv==0.9.22 # Pin to match local version (newer uv has stricter TOML parsing; nemo-run fails) - - uv sync --all-extras script: + - uv sync --all-extras - uv run pre-commit run --all-files rules: - # Run on merge requests - if: $CI_PIPELINE_SOURCE == "merge_request_event" - # Run on main and dev branches - if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_BRANCH == "dev" - # Run on feature branches - if: $CI_COMMIT_BRANCH =~ /^feature\// # DCO sign-off check – only meaningful on MR pipelines where we validate @@ -46,12 +42,9 @@ lint: dco-check: stage: lint image: python:3.12-slim - before_script: - - apt-get update && apt-get install -y git script: - python scripts/check_dco.py rules: - # Only run on merge requests – not on main or feature branch pushes - if: $CI_PIPELINE_SOURCE == "merge_request_event" # ============================================================================= @@ -60,15 +53,13 @@ dco-check: test: stage: test image: python:3.12-slim - before_script: - - apt-get update && apt-get install -y git - - pip install uv==0.9.22 - - uv venv --python 3.12 - - uv pip install pytest pytest-cov pytest-timeout - - uv pip install PyYAML omegaconf rich - - uv pip install -e . --no-deps script: - - .venv/bin/pytest tests/ -v --tb=short + # Lightweight install: skip heavy core deps (nemo-skills ~200+ packages) + # that unit tests don't need. Only install the project + test deps. + - uv venv --python 3.12 + - uv pip install -e ".[dev]" --no-deps + - uv pip install pytest pytest-cov pytest-xdist pytest-timeout PyYAML omegaconf rich + - uv run pytest tests/ -v --tb=short rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" @@ -81,25 +72,24 @@ test: build: stage: build image: python:3.12-slim + variables: + GIT_DEPTH: 0 before_script: - - apt-get update && apt-get install -y git - - git fetch --tags --force # Ensure tags are available for version detection + - apt-get update -qq && apt-get install -y -qq git > /dev/null + - git fetch --tags --force - git describe --tags || echo "No tags found" - - pip install build hatch-vcs + - pip install -q build hatch-vcs script: - python -m build - ls -la dist/ - # Show the version that was built - echo "Built version:" && ls dist/*.whl | sed 's/.*nvflow-\(.*\)-py3.*/\1/' artifacts: paths: - dist/ expire_in: 1 week rules: - # Build on main and dev branches - if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_BRANCH == "dev" - # Build on version tags - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ # ============================================================================= @@ -108,8 +98,9 @@ build: publish: stage: publish image: python:3.12-slim + interruptible: false before_script: - - pip install twine + - pip install -q twine script: - | echo "Publishing to GitLab Package Registry..." @@ -126,7 +117,5 @@ publish: dependencies: - build rules: - # Publish on main branch only (not dev) - if: $CI_COMMIT_BRANCH == "main" - # Publish on version tags - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ diff --git a/INSTALL.md b/INSTALL.md index 6d0627b..34840c8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -7,8 +7,9 @@ Quick setup guide for NVFlow - a lightweight orchestration tool for Slurm cluste 1. [Prerequisites](#prerequisites) 2. [Setup Containers](#setup-containers) 3. [Download Models](#download-models) -4. [Configure Your Cluster](#configure-your-cluster) -5. [Verify Installation](#verify-installation) +4. [Setup NeMo-RL & NeMo-Gym Sources (for GRPO)](#setup-nemo-rl--nemo-gym-sources-for-grpo) +5. [Configure Your Cluster](#configure-your-cluster) +6. [Verify Installation](#verify-installation) --- @@ -37,9 +38,11 @@ yq --version # macOS brew install yq -# Linux +# Linux (auto-detects architecture) +# Supported platforms: linux_amd64, linux_arm64, linux_arm, linux_386, etc. mkdir -p $HOME/bin -wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O $HOME/bin/yq +ARCH=$(uname -m); case "$ARCH" in x86_64) ARCH=amd64 ;; aarch64) ARCH=arm64 ;; armv7l) ARCH=arm ;; i686) ARCH=386 ;; esac +wget "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${ARCH}" -O $HOME/bin/yq chmod +x $HOME/bin/yq # Add to PATH (if $HOME/bin not already in PATH) @@ -66,12 +69,13 @@ NeMo-Skills requires Docker containers converted to `.sqsh` format for running o **Required containers (4):** -| Container | Source | Action | -|-----------|--------|--------| -| `nemo-skills` | NeMo-Skills Dockerfiles | **Build** (see Step 1) | -| `vllm` | NeMo-Skills Dockerfiles or `vllm/vllm-openai` | **Build** or pull from Docker Hub | -| `sglang` | `lmsysorg/sglang` | Pull from Docker Hub | -| `nemo-rl` | NeMo-Skills Dockerfiles | **Build** (see Step 1) | +| Container | Source | Tested Version | Action | +|-----------|--------|----------------|--------| +| `nemo-skills` | NeMo-Skills Dockerfiles | NeMo-Skills @ `0229040` | **Build** (see Step 1a) | +| `vllm` | Docker Hub | `vllm/vllm-openai:v0.18.1` | **Pull** (standalone SDG/eval) | +| `vllm-grpo` | Docker Hub | `vllm/vllm-openai:v0.17.1` | **Pull** (standalone GRPO rollouts/judge) | +| `sglang` | Docker Hub | `lmsysorg/sglang:v0.5.10.post1` | **Pull** (no build needed) | +| `nemo-rl` | NGC | `nvcr.io/nvidia/nemo-rl:v0.6.0` | **Pull** from NGC (no build needed) | **Optional containers** (not currently used by any NVFlow recipes): @@ -80,9 +84,9 @@ NeMo-Skills requires Docker containers converted to `.sqsh` format for running o | `megatron` | NeMo-Skills Dockerfiles | Build | | `sandbox` | NeMo-Skills Dockerfiles | Build | | `verl` | NeMo-Skills Dockerfiles | Build | -| `trtllm` | `nvcr.io/nvidia/tensorrt-llm/release` | Pull from NGC | +| `trtllm` | `nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc8` | Pull from NGC | -### Step 1: Build Docker Images +### Step 1a: Build NeMo-Skills Containers Clone the NeMo-Skills repo at the **exact commit pinned by NVFlow** to ensure compatibility. The pinned commit is defined in [`pyproject.toml`](pyproject.toml): @@ -90,82 +94,110 @@ Clone the NeMo-Skills repo at the **exact commit pinned by NVFlow** to ensure co # Clone NeMo-Skills and check out the pinned commit git clone https://github.com/NVIDIA/NeMo-Skills.git cd NeMo-Skills -git checkout 7d6c49a51efb441b61db3e78f6ffa2f04c9a68ef +git checkout 022904023ad7a83a87662a313cf72e7df5891d55 ``` > **Tip:** Always use the commit hash from `pyproject.toml` (search for `nemo-skills @`). Building from a different version may cause incompatibilities. -Build the required images using the [NeMo-Skills Dockerfiles](https://github.com/NVIDIA/NeMo-Skills/tree/7d6c49a51efb441b61db3e78f6ffa2f04c9a68ef/dockerfiles): +Build the `nemo-skills` container using the [NeMo-Skills Dockerfiles](https://github.com/NVIDIA/NeMo-Skills/tree/022904023ad7a83a87662a313cf72e7df5891d55/dockerfiles): ```bash -# Build the required containers +# Build with the helper script ./dockerfiles/build.sh dockerfiles/Dockerfile.nemo-skills -./dockerfiles/build.sh dockerfiles/Dockerfile.vllm -./dockerfiles/build.sh dockerfiles/Dockerfile.nemo-rl # Or build directly with docker docker build -t nemo-skills:latest -f dockerfiles/Dockerfile.nemo-skills . -docker build -t nemo-skills-vllm:latest -f dockerfiles/Dockerfile.vllm . -docker build -t nemo-skills-nemo-rl:latest -f dockerfiles/Dockerfile.nemo-rl . ``` -> **Note:** For `vllm`, you can alternatively pull a pre-built image directly from Docker Hub (`vllm/vllm-openai`) instead of building from the Dockerfile. +For `vllm`, `vllm-grpo`, and `sglang`, pull pre-built images directly from Docker Hub (no build needed): + +```bash +docker pull vllm/vllm-openai:v0.18.1 # standalone for SDG/eval +docker pull vllm/vllm-openai:v0.17.1 # standalone for GRPO rollouts/judge +docker pull lmsysorg/sglang:v0.5.10.post1 +``` + +For optional containers (`megatron`, `sandbox`, `verl`), build them the same way using their respective Dockerfiles. For arm64 builds, see the [multi-platform instructions](https://github.com/NVIDIA/NeMo-Skills/tree/022904023ad7a83a87662a313cf72e7df5891d55/dockerfiles#building-for-arm64aarch64). + +### Step 1b: Pull NeMo-RL Container (for SFT and GRPO) + +The `nemo-rl` container is available as a pre-built image on NGC: + +```bash +docker pull nvcr.io/nvidia/nemo-rl:v0.6.0 +``` + +Alternatively, build from source using the [NeMo-RL repository](https://github.com/NVIDIA-NeMo/RL): -For optional containers (`megatron`, `sandbox`, `verl`), build them the same way using their respective Dockerfiles. For arm64 builds, see the [multi-platform instructions](https://github.com/NVIDIA/NeMo-Skills/tree/7d6c49a51efb441b61db3e78f6ffa2f04c9a68ef/dockerfiles#building-for-arm64aarch64). +```bash +git clone https://github.com/NVIDIA-NeMo/RL.git +cd RL +git checkout v0.6.0 +git submodule update --init --recursive +``` + +Follow the [NeMo-RL Docker build instructions](https://github.com/NVIDIA-NeMo/RL/blob/main/docs/docker.md#building-the-release-image) to build the release image, then tag and push it to your registry alongside the NeMo-Skills containers. ### Step 2: Push Images to a Registry After building, push the images to a container registry accessible from your cluster (Docker Hub, NGC, or a private registry): ```bash -# Tag and push the images you built +# Tag and push the NeMo-Skills container docker tag nemo-skills:latest your-registry/nemo-skills:latest docker push your-registry/nemo-skills:latest -docker tag nemo-skills-vllm:latest your-registry/nemo-skills-vllm:latest +# Tag and push vllm (pulled from Docker Hub) +docker tag vllm/vllm-openai:v0.18.1 your-registry/nemo-skills-vllm:latest docker push your-registry/nemo-skills-vllm:latest -docker tag nemo-skills-nemo-rl:latest your-registry/nemo-skills-nemo-rl:latest +# Tag and push NeMo-RL (pulled from NGC) +docker tag nvcr.io/nvidia/nemo-rl:v0.6.0 your-registry/nemo-skills-nemo-rl:latest docker push your-registry/nemo-skills-nemo-rl:latest +# sglang can be pulled directly by enroot (no push needed unless your +# cluster cannot reach Docker Hub) + # Repeat for any optional images you built (e.g., megatron, sandbox, verl) ``` > **Why push?** Slurm cluster nodes typically don't have Docker installed, so `enroot` needs to pull images from a registry. Pushing to a registry also lets the automated setup script work. -### Step 3: Update `containers.yaml` +### Step 3: Create Your Container Config -Edit [`cluster_configs/containers.yaml`](cluster_configs/containers.yaml) to update image references with your registry paths: +`containers.yaml` is a **template** with placeholder values -- do not edit it directly. Instead, copy it to a personal file and fill in your registry paths: + +```bash +cp cluster_configs/containers.yaml cluster_configs/my_containers.yaml +``` + +Edit `my_containers.yaml` with your actual registry paths: ```yaml containers: - # Required - pull from official registry (no changes needed) - sglang: lmsysorg/sglang:v0.5.4 - - # Required - replace with your own built images nemo-skills: your-registry/nemo-skills:latest - vllm: your-registry/nemo-skills-vllm:latest - nemo-rl: your-registry/nemo-skills-nemo-rl:latest - - # Optional - # megatron: your-registry/nemo-skills-megatron:latest - # sandbox: your-registry/nemo-skills-sandbox:latest - # verl: your-registry/nemo-skills-verl:latest + vllm: your-registry/nemo-skills-vllm:latest # v0.18.1 for SDG/eval + vllm-grpo: vllm/vllm-openai:v0.17.1 # v0.17.1 for GRPO rollouts/judge + nemo-rl: nvcr.io/nvidia/nemo-rl:v0.6.0 # or your-registry/nemo-skills-nemo-rl:latest + sglang: lmsysorg/sglang:v0.5.10.post1 ``` +> **Note:** `my_containers.yaml` is gitignored (`cluster_configs/*.yaml` pattern), so your registry paths stay local and won't be committed. + ### Step 4: Convert to .sqsh Format Choose one of the following methods to convert your container images to `.sqsh` format for Slurm. #### Option A: Automated Setup (Recommended) -Use the setup script to download from your registry and convert all containers in parallel: +Use the setup script to download from your registry and convert all containers in parallel. Pass your personal config with `--config`: ```bash -sbatch --account=YOUR_ACCOUNT scripts/setup_containers.sh ./containers +# Run from a cluster login node (sbatch requires Slurm access) +sbatch --account=YOUR_ACCOUNT scripts/setup_containers.sh --config cluster_configs/my_containers.yaml ./containers ``` -The script reads image references from `cluster_configs/containers.yaml`, pulls them via `enroot`, and converts to `.sqsh` format. See [the script](scripts/setup_containers.sh) for options (`--platform`, `--force`). +The `--config` flag is required -- the script reads image references from the specified YAML file, pulls them via `enroot`, and converts to `.sqsh` format. See [the script](scripts/setup_containers.sh) for additional options (`--platform`, `--force`). **Check progress:** ```bash @@ -180,10 +212,12 @@ Convert images one at a time using `enroot` on a cluster node: # Import from your registry enroot import docker://your-registry/nemo-skills:latest enroot import docker://your-registry/nemo-skills-vllm:latest -enroot import docker://your-registry/nemo-skills-nemo-rl:latest +enroot import docker://nvcr.io/nvidia/nemo-rl:v0.6.0 -# Import from official registries (for sglang, etc.) -enroot import docker://lmsysorg/sglang:v0.5.4 +# Import from official registries +enroot import docker://vllm/vllm-openai:v0.18.1 +enroot import docker://vllm/vllm-openai:v0.17.1 +enroot import docker://lmsysorg/sglang:v0.5.10.post1 ``` Move the resulting `.sqsh` files to your cluster's container storage path. @@ -202,16 +236,38 @@ Move the resulting `.sqsh` files to your cluster's container storage path. ### Using hf download -**Note:** `hf` CLI is included with nemo-skills (via `huggingface-hub`). +**Note:** `hf` CLI is included with nemo-skills (via `huggingface-hub`). Some models are gated and require authentication -- export your HuggingFace token before downloading: ```bash -# Download model to your cluster storage -uv run hf download Qwen/Qwen3-4B-Instruct-2507 \ - --local-dir /path/to/models/hf_models/Qwen/Qwen3-4B-Instruct-2507 +export HF_TOKEN= +``` -# Example: -uv run hf download Qwen/Qwen3-4B-Instruct-2507 \ - --local-dir /lustre/fs1/.../models/hf_models/Qwen/Qwen3-4B-Instruct-2507 +Download models to your cluster's HuggingFace models directory. The examples below show the models used by the finance recipe workflows -- download only the ones you need: + +```bash +# GRPO policy model (Qwen3-30B-A3B, MoE — used in grpo/qwen3_30b_a3b.yaml) +uv run hf download Qwen/Qwen3-30B-A3B \ + --local-dir /path/to/models/hf_models/Qwen/Qwen3-30B-A3B + +# GRPO / eval judge model (GPT-OSS-120B — used for rollout judging and eval) +uv run hf download openai/gpt-oss-120b \ + --local-dir /path/to/models/hf_models/openai/gpt-oss-120b +``` + +For the **quick-start demo** (see [quick-start.md](docs/recipes/finance/quick-start.md)), download these additional models: + +```bash +# Demo policy model (Qwen3-4B — used in sft/qwen3_4b.yaml and grpo/qwen3_4b.yaml) +uv run hf download Qwen/Qwen3-4B \ + --local-dir /path/to/models/hf_models/Qwen/Qwen3-4B + +# Demo SDG generation + eval baseline (GPT-OSS-20B) +uv run hf download openai/gpt-oss-20b \ + --local-dir /path/to/models/hf_models/openai/gpt-oss-20b + +# Eval baseline (Gemma 3 4B IT) +uv run hf download google/gemma-3-4b-it \ + --local-dir /path/to/models/hf_models/google/gemma-3-4b-it ``` **Storage location:** Models should go in your mounted HuggingFace models directory (see cluster config `mounts` section). @@ -231,14 +287,73 @@ Reference models using the **container mount path** (`/hf_models`): ```yaml stage_kwargs: - model: /hf_models/Qwen/Qwen3-4B-Instruct-2507 # Path inside container + model: /hf_models/Qwen/Qwen3-4B # Path inside container server_type: sglang ``` +**Models needed per workflow:** + +| Model | Demo SDG | Demo SFT | Demo GRPO | Demo Eval | Production GRPO | +|-------|:--------:|:--------:|:---------:|:---------:|:---------------:| +| `Qwen/Qwen3-4B` | | ✓ | ✓ | ✓ | | +| `openai/gpt-oss-20b` | ✓ | | | ✓ | | +| `google/gemma-3-4b-it` | | | | ✓ | | +| `openai/gpt-oss-120b` | | | ✓ | | ✓ | +| `Qwen/Qwen3-30B-A3B` | | | | | ✓ | + **Tip:** Download commonly used models once and reuse across all workflows. --- +## Setup NeMo-RL & NeMo-Gym Sources (for GRPO) + +> **Skip this section** if you're only running SDG/eval workflows. This setup is needed for GRPO RL training and recommended for multi-node SFT. + +Both NeMo-RL and NeMo-Gym source trees are overlay-mounted into the NeMo-RL container via cluster config mounts. This ensures the container uses the exact tested source code. + +### NeMo-RL Source Clone + +Mount the NeMo-RL source into the container at `/opt/NeMo-RL`. If you already cloned it in [Step 1b](#step-1b-build-nemo-rl-container-for-sft-and-grpo), reuse that clone: + +```bash +# Reuse the clone from Step 1b, or: +git clone https://github.com/NVIDIA-NeMo/RL.git +cd RL +git checkout v0.6.0 +git submodule update --init --recursive +``` + +### NeMo-Gym Clone + +Clone NeMo-Gym and mount it inside the NeMo-RL source tree. The Gym overlay is independent from the Gym submodule inside RL -- this lets RL and Gym evolve on separate branches. Check your workflow config (e.g., `grpo/base.yaml`) for the tested Gym branch or commit: + +```bash +git clone https://github.com/NVIDIA-NeMo/Gym.git +cd Gym +git checkout ude/finance-sec-search # finance_agent environment (until merged to main) +``` + +### Cluster Config Mounts + +Add both overlay mounts to your cluster config (see [Configure Your Cluster](#configure-your-cluster)): + +```yaml +mounts: + - /path/to/RL:/opt/NeMo-RL + - /path/to/Gym:/opt/NeMo-RL/3rdparty/Gym-workspace/Gym +``` + +No local `uv sync` is needed for either -- the container's `installation_command` handles dependency installation at runtime. + +### Prefetch SEC Filings Cache (for `finance_sec_search`) + +If using the `finance_sec_search` NeMo-Gym environment, you must prefetch the SEC filings cache to a shared mounted path. The default `~/.cache` does **not** work inside Slurm containers. + +1. Follow the prefetch instructions in `Gym/resources_servers/finance_sec_search/README.md` +2. Set `cache_dir` in the environment config overlay to point to the shared mounted path + +--- + ## Configure Your Cluster ### Step 1: Create Your Cluster Config @@ -256,7 +371,7 @@ Edit `cluster_configs/my_cluster.yaml` and replace all `` values: 2. **Slurm account/partition** - Run `sacctmgr show associations user=$USER` and `sinfo` 3. **Container paths** - Copy from `outputs/logs/slurm-containers-.out` after running setup_containers.sh 4. **Mount points** - Map your cluster paths to container paths -5. **Environment variables** - Set `HF_HOME` and any API keys +5. **Environment variables** - Set `HF_HOME` to a path visible inside the container (see [env_vars docs](docs/cluster-configuration.md#environment-variables)) and any API keys > **Note:** The template includes detailed comments for each section. Your personal config (`my_cluster.yaml`) is gitignored to protect secrets. > @@ -304,9 +419,10 @@ uv sync --reinstall # macOS brew install yq -# Linux +# Linux (auto-detects architecture) mkdir -p $HOME/bin -wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O $HOME/bin/yq +ARCH=$(uname -m); case "$ARCH" in x86_64) ARCH=amd64 ;; aarch64) ARCH=arm64 ;; armv7l) ARCH=arm ;; i686) ARCH=386 ;; esac +wget "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${ARCH}" -O $HOME/bin/yq chmod +x $HOME/bin/yq # Add to PATH if needed @@ -335,6 +451,12 @@ ssh -i @ - Check file exists: `ls -l /.sqsh` - Re-run container setup if needed +### HF_HOME / cache "No such file or directory" +- `HF_HOME` (and other path-valued env vars) must resolve to a path **visible inside the container** +- Use a mount destination (e.g., `/workspace/cache/huggingface`) or a host path that is transparently mounted (e.g., `/shared/.../cache` when `- /shared:/shared` is in `mounts`) +- Paths that exist only on the host and have no corresponding mount will fail with `No such file or directory` +- Common mistake: using `$HOME` or `~/.cache` -- these do not resolve inside containers unless explicitly mounted + ### Slurm jobs won't submit - Verify account: `sacctmgr show associations user=$USER` - Check partition: `sinfo -p ` @@ -385,7 +507,9 @@ Then head back to the [README.md](README.md#-quick-start) Quick Start section to ## Reference - **NeMo-Skills**: https://github.com/NVIDIA/NeMo-Skills -- **NeMo-Skills Dockerfiles**: https://github.com/NVIDIA/NeMo-Skills/tree/main/dockerfiles +- **NeMo-Skills Dockerfiles**: https://github.com/NVIDIA/NeMo-Skills/tree/022904023ad7a83a87662a313cf72e7df5891d55/dockerfiles +- **NeMo-RL**: https://github.com/NVIDIA-NeMo/RL +- **NeMo-RL Docker Build**: https://github.com/NVIDIA-NeMo/RL/blob/main/docs/docker.md#building-the-release-image - **Official Container Config**: https://github.com/NVIDIA/NeMo-Skills/blob/main/cluster_configs/example-slurm.yaml - **Slurm Docs**: https://slurm.schedmd.com/ - **Enroot**: https://github.com/NVIDIA/enroot diff --git a/README.md b/README.md index e32bf90..c995b75 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Key features: - **Reusability and reproducibility** with a structured, stage-based architecture - **Flexible execution** via CLI (`nflow`), Python scripts, or programmatic API - **Cluster integration** with native Slurm support -- **Built on NeMo** leveraging NeMo-Skills and NeMo-RL infrastructure. Data Designer and NeMo-Gym are coming soon. +- **Built on NeMo** leveraging NeMo-Skills, NeMo-RL, and NeMo-Gym infrastructure Example use case: The finance recipe demonstrates a complete pipeline: download SEC filings → generate synthetic Q&A data → fine-tune models → evaluate performance, producing 300K+ synthetic Q&A pairs. @@ -85,18 +85,13 @@ pipeline_stages: ## 📋 Prerequisites - **Git** - to clone the repository -- **uv 0.9.22** - Python package manager ([docs](https://docs.astral.sh/uv/)) +- **uv** - Python package manager ([docs](https://docs.astral.sh/uv/)) ```bash -# Install uv 0.9.22 (REQUIRED - newer versions have breaking TOML parsing changes) -pip install uv==0.9.22 - -# Verify installation -uv --version # Should show: uv 0.9.22 +curl -LsSf https://astral.sh/uv/install.sh | sh +source $HOME/.local/bin/env ``` -> **⚠️ Important:** uv version 0.9.22 is required. Newer versions (0.9.29+) have stricter TOML parsing that's incompatible with upstream dependency (nemo-run) syntax. This is a temporary requirement until nemo-run fixes their `pyproject.toml`. - ## 📦 Installation ```bash @@ -255,9 +250,9 @@ Simple demonstration recipe for learning the framework: End-to-end pipeline for generating synthetic financial Q&A data from SEC filings and training financial reasoning models. **Quick Links:** -- [Quick Start (~1.5 hour demo)](docs/recipes/finance/quick-start.md) - Get started quickly with 7 companies +- [Quick Start (~3 hour demo)](docs/recipes/finance/quick-start.md) - Get started quickly with 7 companies - [Workflow Guides](docs/recipes/finance/workflows/) - Detailed guides for all 6 workflows -- [Stage Reference](docs/recipes/finance/stages/) - Technical specifications for 27 stages +- [Stage Reference](docs/recipes/finance/stages/) - Technical specifications for 42 stages **Pipeline:** ``` @@ -265,7 +260,7 @@ download-sec → template-sdg / document-sdg → sft → eval → grpo ``` **Features:** -- 27 stages across 6 workflows +- 42 stages across 6 workflows - Two SDG approaches (template-based & document-grounded) - Multiple model support (GPT-OSS-120B, Qwen3, Nemotron) - Produces 80K+ synthetic Q&A pairs @@ -276,8 +271,8 @@ download-sec → template-sdg / document-sdg → sft → eval → grpo ```bash nflow list-stages # List all stages (hierarchical) nflow list-stages --recipe finance # Filter by recipe -nflow list-stages --recipe finance --workflow training_sft # Filter by workflow -nflow stage-info STAGE_PATH # Stage details (e.g., finance.training_sft.sft) +nflow list-stages --recipe finance --workflow sft # Filter by workflow +nflow stage-info STAGE_PATH # Stage details (e.g., finance.sft.sft) nflow stage-info STAGE --recipe R --workflow W # Or with flags nflow validate --config FILE # Validate config nflow run STAGE --config FILE # Run specific stage (short name) @@ -311,4 +306,4 @@ Apache-2.0 Built on: - [NeMo-Skills](https://github.com/NVIDIA/NeMo-Skills) -- [NeMo-RL](https://github.com/NVIDIA/NeMo-RL) +- [NeMo-RL](https://github.com/NVIDIA-NeMo/RL) diff --git a/cluster_configs/containers.yaml b/cluster_configs/containers.yaml index 2dd3fe5..b2780fd 100644 --- a/cluster_configs/containers.yaml +++ b/cluster_configs/containers.yaml @@ -3,8 +3,12 @@ # This file defines the Docker images used by setup_containers.sh # to create .sqsh container images for Slurm. # -# Before running setup_containers.sh, update the placeholders -# below with your own registry paths. See INSTALL.md for build instructions. +# This is a TEMPLATE -- do not edit this file directly. +# Copy it to a personal file and update with your registry paths: +# +# cp cluster_configs/containers.yaml cluster_configs/my_containers.yaml +# # Edit my_containers.yaml with your registry paths +# sbatch --account= scripts/setup_containers.sh --config cluster_configs/my_containers.yaml ./containers # # Format: # - Simple string: image reference (e.g., your-registry/nemo-skills:latest) @@ -12,23 +16,32 @@ containers: # --------------------------------------------------------------------------- - # Required: Build from NeMo-Skills Dockerfiles, then push to your registry - # See INSTALL.md Step 1-2 for build and push instructions. + # Required: Build from NeMo-Skills Dockerfiles (see INSTALL.md Step 1a) # --------------------------------------------------------------------------- + # Tested: NeMo-Skills @ 0229040 nemo-skills: /nemo-skills: + + # --------------------------------------------------------------------------- + # Required: Pull pre-built from Docker Hub / NGC (no build needed) + # --------------------------------------------------------------------------- + # Tested: vllm/vllm-openai:v0.18.1 (standalone SDG/eval) vllm: /nemo-skills-vllm: - nemo-rl: /nemo-skills-nemo-rl: + # Tested: vllm/vllm-openai:v0.17.1 (standalone GRPO rollouts/judge) + vllm-grpo: vllm/vllm-openai:v0.17.1 + # Tested: lmsysorg/sglang:v0.5.10.post1 + sglang: lmsysorg/sglang:v0.5.10.post1 # --------------------------------------------------------------------------- - # Required: Pull from official registry (no build needed) + # Required: Pull from NGC (see INSTALL.md Step 1b) # --------------------------------------------------------------------------- - sglang: lmsysorg/sglang:v0.5.4 + # Tested: nvcr.io/nvidia/nemo-rl:v0.6.0 + nemo-rl: nvcr.io/nvidia/nemo-rl:v0.6.0 # --------------------------------------------------------------------------- # Optional: Not currently used by NVFlow recipes # Uncomment and update if needed for your workflows. # --------------------------------------------------------------------------- - # trtllm: nvcr.io/nvidia/tensorrt-llm/release:1.0.0 + # trtllm: nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc8 # megatron: /nemo-skills-megatron: # sandbox: /nemo-skills-sandbox: # verl: /nemo-skills-verl: diff --git a/cluster_configs/template-slurm.yaml b/cluster_configs/template-slurm.yaml index aaa3819..077633a 100644 --- a/cluster_configs/template-slurm.yaml +++ b/cluster_configs/template-slurm.yaml @@ -5,6 +5,13 @@ # # Then update all values with your settings. # +# Container versions tested with this release: +# nemo-skills: NeMo-Skills @ 0229040 +# vllm: vllm/vllm-openai v0.18.1 (standalone SDG/eval) +# vllm-grpo: vllm/vllm-openai v0.17.1 (standalone GRPO rollouts/judge) +# sglang: lmsysorg/sglang v0.5.10.post1 +# nemo-rl: nvcr.io/nvidia/nemo-rl:v0.6.0 (includes vLLM 0.17.1 colocated) +# # Reference: https://github.com/NVIDIA/NeMo-Skills executor: slurm @@ -52,6 +59,9 @@ job_dir: # e.g., /lustre/users//nvfl account: # Run: sacctmgr show associations user=$USER partition: # Run: sinfo to see available partitions cpu_partition: # Optional: CPU-only partition for container setup +gpus_per_node: 8 # GPUs per node (run: sinfo -p -o "%G") +# dependency_type: afterok # Caution: breaks training dependent_jobs (TIMEOUT != COMPLETED) +# # Leave as default (afterany) until per-stage types are supported job_name_prefix: "nvflow-" # ============================================================================= @@ -70,10 +80,11 @@ extra_sandbox_args: # After converting containers to .sqsh format (see INSTALL.md), paste paths here. containers: # Required containers - nemo-skills: /nemo-skills.sqsh - vllm: /vllm.sqsh - sglang: /sglang.sqsh - nemo-rl: /nemo-rl.sqsh + nemo-skills: /nemo-skills.sqsh # Orchestration client (eval, SDG, data prep) + vllm: /vllm.sqsh # vLLM v0.18.1 standalone (SDG, eval) + vllm-grpo: /vllm-grpo.sqsh # vLLM v0.17.1 standalone (GRPO rollouts, judge) + sglang: /sglang.sqsh # sglang inference server (SDG stages 3-5) + nemo-rl: /nemo-rl.sqsh # NeMo-RL v0.6.0 for SFT and GRPO training # Optional containers (not currently used by NVFlow recipes) # trtllm: /trtllm.sqsh # megatron: /megatron.sqsh @@ -87,6 +98,12 @@ containers: mounts: - :/hf_models # HuggingFace models - :/workspace # Your workspace + # --- GRPO / RL Training (required for collect_rollouts and training stages) --- + # NeMo-RL source mount: overlays the container's built-in /opt/NeMo-RL. + - :/opt/NeMo-RL + # Gym overlay: mount your NeMo-Gym clone inside the NeMo-RL source tree. + # Harmless for SFT/SDG/eval -- only accessed by GRPO stages. + - :/opt/NeMo-RL/3rdparty/Gym-workspace/Gym # Add more mounts as needed: # - /lustre/data:/data @@ -100,14 +117,24 @@ timeouts: # ============================================================================= # Environment Variables # ============================================================================= -# HF_HOME is required and must be a mounted path +# IMPORTANT: Every path in env_vars must be visible inside the container. +# It must be either a mount destination from your mounts: section above +# (e.g., /workspace, /hf_models) or a host path that is transparently +# mounted (e.g., /shared/... when "- /shared:/shared" is in mounts). +# Paths that only exist on the host cause "No such file or directory" at runtime. +# HF_HOME is required. env_vars: - - HF_HOME=/cache/huggingface + - HF_HOME=/cache/huggingface # e.g., /workspace/cache/huggingface - NCCL_DEBUG=INFO - PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:512 - CUDA_DEVICE_MAX_CONNECTIONS=1 + - TOKENIZERS_PARALLELISM=false # Disable HF tokenizer Rayon threads (prevents vLLM RefCell race) - VIRTUAL_ENV= # Unset to prevent host venv from interfering with container - VIRTUAL_ENV_PROMPT= # Unset venv prompt + # Rebuild Ray venvs when NeMo-RL source mount changes (e.g., new branch/commit). + # Without this, workers reuse stale cached venvs and may fail with import errors. + # Safe to leave enabled — only rebuilds when the source tree actually changes. + # - NRL_FORCE_REBUILD_VENVS=true # API keys (keep these secret, don't commit to git!) # - HF_TOKEN= # - WANDB_API_KEY= diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 0b4c783..1d6c5e0 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -285,11 +285,11 @@ nvflow/ # Stage registration uses decorator pattern @StageRegistry.register( recipe="finance", - workflow="training_sft", + workflow="sft", stage="sft" ) class SFTStage(BaseStage): - workflow = "training_sft" + workflow = "sft" def execute(self, config, cluster, expname, run_after=None): # Implementation @@ -301,7 +301,7 @@ class SFTStage(BaseStage): ```yaml recipe: finance workflow: - name: training_sft + name: sft type: training cluster: my_cluster @@ -318,8 +318,7 @@ stages: output_dir: /data/processed training: - num_nodes: 32 - num_gpus_per_node: 8 + total_gpus: 256 dependencies: - data_transformation - prepare_for_sft @@ -358,7 +357,7 @@ sequenceDiagram WorkflowRunner->>Stage: validate_config(config) WorkflowRunner->>Stage: execute(config, cluster, expname, run_after) - Stage->>NemoSkills: Call nemo-skills pipeline + Stage->>NemoSkills: Submit job (NeMo-RL direct for training, nemo-skills for data/eval) NemoSkills->>Slurm: Submit job with dependencies Slurm-->>NemoSkills: Job ID NemoSkills-->>Stage: Job submitted @@ -522,19 +521,20 @@ Benchmarks: Financial reasoning tasks #### **Workflow 6: GRPO RL Training** ``` Stages: - 1. data_transformation - SDG cleanup to model-agnostic schema - 2. apply_prompt_template - Apply prompt template + extract answer - 3. convert_to_responses_api - Convert to NeMo-Gym Responses API format - 4. train_validation_split - Split into train/val sets + 1. validate_questions - Validate format + deduplicate + 2. data_transformation - SDG cleanup to model-agnostic schema + 3. apply_prompt_template - Apply prompt template + extract answer + 4. convert_to_responses_api - Convert to NeMo-Gym Responses API format 5. prepare_data - Add agent routing fields - 6. collect_rollouts - Rollout collection + reward profiling - 7. compute_rewards - [Optional] Re-judge with different model - 8. training - GRPO training with NeMo-Gym - 9. eval - Evaluate checkpoints on benchmarks + 6. prefetch_cache - Prefetch SEC filings cache + 7. collect_rollouts - Rollout collection + reward profiling + 8. train_validation_split - Split into train/val sets + 9. training - GRPO training with NeMo-Gym + 10. eval - Evaluate checkpoints on benchmarks Output: RL-trained model + eval results -GPU: 8 GPUs (1 node for demo) -Model: Qwen3-4B (demo), extensible to larger models +GPU: 16 GPUs (2 nodes for demo), 64 GPUs (8 nodes for production) +Model: Qwen3-4B dense (demo, FSDP v2), Qwen3-30B-A3B MoE (production, Megatron) ``` ### Finance Recipe Component Diagram @@ -544,12 +544,12 @@ graph TB subgraph "Finance Recipe Structure" direction TB - subgraph "Stages (27 total)" + subgraph "Stages (42 total)" direction LR SDG[SDG Stages
12 stages] SFT[SFT Stages
4 stages] Eval[Eval Stages
2 stages] - RL[RL Stages
9 stages] + RL[RL Stages
10 stages] end subgraph "Workflows (6 total)" @@ -558,7 +558,7 @@ graph TB W3[document-sdg
7 stages] W4[sft
6 stages] W5[eval
9 stages] - W6[grpo
9 stages] + W6[grpo
10 stages] end subgraph "Prompts" @@ -671,25 +671,25 @@ graph TB ┌─────────────────────────────────────────────────────┐ │ Container Images (.sqsh format) │ ├─────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ nemo-skills │ │ vLLM │ │ -│ │ (0.7.1) │ │ (v0.10.2) │ │ -│ └──────────────┘ └──────────────┘ │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ SGLang │ │ NeMo-RL │ │ -│ │ (v0.5.4) │ │ (0.7.0) │ │ -│ └──────────────┘ └──────────────┘ │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ NeMo FW │ │ PyTorch │ │ -│ │ │ │ │ │ -│ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ nemo-skills │ │ vLLM │ │ +│ │ (0229040) │ │ (v0.18.1) │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ SGLang │ │ NeMo-RL │ │ +│ │ (v0.5.10) │ │ (v0.6.0) │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ NeMo FW │ │ PyTorch │ │ +│ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────┐ -│ Shared Filesystem Mounts │ +│ Shared Filesystem Mounts │ ├─────────────────────────────────────────────────────┤ │ /workspace → /lustre/.../workspace │ │ /hf_models → /lustre/.../models/hf_models │ diff --git a/docs/architecture/ARCHITECTURE_INDEX.md b/docs/architecture/ARCHITECTURE_INDEX.md index ef72c5e..dc4dbd1 100644 --- a/docs/architecture/ARCHITECTURE_INDEX.md +++ b/docs/architecture/ARCHITECTURE_INDEX.md @@ -63,7 +63,7 @@ The NVFlow architecture is documented across multiple files, each serving a spec 3. Core framework components (detailed) 4. Hierarchical organization (Recipe → Workflow → Stage) 5. Complete execution flow with sequence diagrams -6. Finance recipe architecture (all 27 stages) +6. Finance recipe architecture (all 42 stages) 7. Deployment architecture and topology 8. Technology stack and integrations 9. Data flow diagrams @@ -115,7 +115,7 @@ The NVFlow architecture is documented across multiple files, each serving a spec 2. **[finance-pipeline.mmd](../diagrams/finance-pipeline.mmd)** - Complete finance recipe pipeline - - All 6 workflows with 27 stages + - All 6 workflows with 42 stages - Data flow from SEC filings to evaluation 3. **[execution-flow.mmd](../diagrams/execution-flow.mmd)** @@ -201,7 +201,7 @@ The NVFlow architecture is documented across multiple files, each serving a spec **Main Files:** 1. **[README.md](../recipes/finance/README.md)** - Recipe overview - - 6 workflows, 27 stages + - 6 workflows, 42 stages - Pipeline architecture - Getting started guide - Command reference @@ -221,7 +221,7 @@ The NVFlow architecture is documented across multiple files, each serving a spec - 06-finance-agent-eval.md 4. **Stage Reference** (in `stages/`) - - Technical specifications for all 27 stages + - Technical specifications for all 42 stages - Input/output formats - Configuration options @@ -264,7 +264,7 @@ NVFlow Documentation │ │ ├─ README.md ................... Recipe overview │ │ ├─ quick-start.md .............. 30-min demo │ │ ├─ workflows/ .................. 7 workflow guides - │ │ ├─ stages/ ..................... 27 stage specifications + │ │ ├─ stages/ ..................... 42 stage specifications │ │ └─ troubleshooting.md .......... Common issues │ │ │ └─ nvflow/recipes/example/ ..... Example recipe (learning) diff --git a/docs/architecture/ARCHITECTURE_QUICK_REFERENCE.md b/docs/architecture/ARCHITECTURE_QUICK_REFERENCE.md index 6a78a58..b40b399 100644 --- a/docs/architecture/ARCHITECTURE_QUICK_REFERENCE.md +++ b/docs/architecture/ARCHITECTURE_QUICK_REFERENCE.md @@ -41,11 +41,11 @@ Workflow (Pipeline: download, sdg, sft, eval, grpo) Stage (Task: generate_answers, training, evaluate) ``` -**Example Path:** `finance.training_sft.sft` → `SFTStage` class +**Example Path:** `finance.sft.sft` → `SFTStage` class --- -## 📊 Finance Recipe Pipeline (6 Workflows, 27 Stages) +## 📊 Finance Recipe Pipeline (6 Workflows, 42 Stages) ``` 1. download-sec (1 stage) @@ -63,7 +63,7 @@ Stage (Task: generate_answers, training, evaluate) 5. eval (2 stages, dynamically expanded) └─ Prepare → Evaluate checkpoints → Compare → Results -6. grpo +6. grpo (10 stages: 9 active + 1 optional) └─ GRPO reinforcement learning workflow ``` @@ -105,7 +105,7 @@ nvflow/ ├── core/ # Framework (BaseStage, Registry, Runner) ├── cli/ # CLI interface (nflow commands) └── recipes/ # Domain-specific implementations - ├── finance/ # 27 stages, 6 workflows + ├── finance/ # 42 stages, 6 workflows │ ├── stages/ # Stage implementations │ ├── workflows/ # YAML configs │ └── prompts/ # Prompt templates @@ -121,7 +121,7 @@ nvflow/ nflow list-stages --recipe finance # Get stage info -nflow stage-info finance.training_sft.sft +nflow stage-info finance.sft.sft # Run single stage nflow run sft --config workflow.yaml diff --git a/docs/architecture/DIAGRAMS_SUMMARY.md b/docs/architecture/DIAGRAMS_SUMMARY.md index a265c18..04c20e2 100644 --- a/docs/architecture/DIAGRAMS_SUMMARY.md +++ b/docs/architecture/DIAGRAMS_SUMMARY.md @@ -28,7 +28,7 @@ A comprehensive set of architectural diagrams and documentation for the NVFlow o 2. **[finance-pipeline.mmd](../diagrams/finance-pipeline.mmd)** - Finance recipe end-to-end pipeline - Complete data flow from SEC filings to model evaluation - - All 6 workflows with 27 stages visualized + - All 6 workflows with 42 stages visualized - Production vs. experimental paths 3. **[execution-flow.mmd](../diagrams/execution-flow.mmd)** - Runtime execution sequence @@ -142,7 +142,7 @@ Use Case: Understanding system boundaries and component relationships ### 2. Finance Pipeline ``` Coverage: -✓ Complete 6-workflow pipeline (27 stages total) +✓ Complete 6-workflow pipeline (42 stages total) ✓ Data acquisition (SEC filings download) ✓ SDG (Template-based & Document-grounded approaches) ✓ Data preparation (Transformation, formatting, splitting) @@ -300,7 +300,7 @@ All architecture diagrams and documentation are part of the NVFlow project and f Built on the NVIDIA NeMo ecosystem: - [NeMo-Skills](https://github.com/NVIDIA/NeMo-Skills) -- [NeMo-RL](https://github.com/NVIDIA/NeMo-RL) +- [NeMo-RL](https://github.com/NVIDIA-NeMo/RL) - [NeMo Framework](https://github.com/NVIDIA/NeMo) --- diff --git a/docs/cluster-configuration.md b/docs/cluster-configuration.md index 4b52996..4228950 100644 --- a/docs/cluster-configuration.md +++ b/docs/cluster-configuration.md @@ -305,16 +305,56 @@ sinfo -o "%P %l" | grep batch Environment variables injected into all job containers. +> **Important:** Every path in `env_vars` must be **visible inside the container**. +> It must be either a mount destination (e.g., `/workspace`, `/hf_models`) or a +> host path that is transparently mounted (e.g., `/shared/data` when +> `- /shared:/shared` is in your `mounts` section). +> +> | Status | Example | Why | +> |--------|---------|-----| +> | Works | `HF_HOME=/workspace/cache/huggingface` | `/workspace` is a mount destination | +> | Works | `HF_HOME=/shared/cache/huggingface` | `/shared` is transparently mounted via `- /shared:/shared` | +> | Fails | `HF_HOME=/home/user/.cache/huggingface` | `/home/user` has no corresponding mount | +> +> If you see `No such file or directory` for HF cache paths, check that the path +> falls under a mount from your `mounts:` section. + **Example:** ```yaml env_vars: - - HF_HOME=/hf_models/cache + # Infrastructure (match template-slurm.yaml order) + - HF_HOME=/cache/huggingface - NCCL_DEBUG=INFO - PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:512 - - OPENAI_API_KEY=sk-... + - CUDA_DEVICE_MAX_CONNECTIONS=1 + - TOKENIZERS_PARALLELISM=false + - VIRTUAL_ENV= + - VIRTUAL_ENV_PROMPT= + # NeMo-RL / GRPO (uncomment as needed) + # - NRL_FORCE_REBUILD_VENVS=true + # API keys (keep secret, don't commit to git!) - HF_TOKEN=hf_... + - OPENAI_API_KEY=sk-... ``` +#### Recommended Variables + +| Variable | Value | Purpose | +|----------|-------|---------| +| `HF_HOME` | `/cache/huggingface` | **Required.** HuggingFace cache directory. Path must be visible inside the container (a mount destination or a transparently-mounted host path) | +| `NCCL_DEBUG` | `INFO` | NCCL debugging output (useful for diagnosing multi-node issues) | +| `PYTORCH_CUDA_ALLOC_CONF` | `max_split_size_mb:512` | Reduces CUDA memory fragmentation | +| `CUDA_DEVICE_MAX_CONNECTIONS` | `1` | Required for sequence parallelism in Megatron | +| `TOKENIZERS_PARALLELISM` | `false` | Disables Rayon multi-threading in the HuggingFace tokenizer Rust backend. Prevents `RuntimeError: Already borrowed` in vLLM 0.17.0 when concurrent requests trigger simultaneous mutable borrows on the tokenizer's `RefCell`. Zero performance impact (tokenization is microseconds vs. seconds for GPU inference). Standard practice across Megatron-LM, Megatron-Bridge, and NeMo-Gym | +| `VIRTUAL_ENV` | *(empty)* | Unset to prevent host virtualenv from leaking into containers | +| `VIRTUAL_ENV_PROMPT` | *(empty)* | Unset to prevent host venv prompt from leaking into containers | + +#### NeMo-RL / GRPO Variables + +| Variable | Value | Purpose | +|----------|-------|---------| +| `NRL_FORCE_REBUILD_VENVS` | `true` | Forces Ray workers to rebuild their virtual environments from the mounted NeMo-RL source tree instead of reusing cached venvs. **Enable this** when you update the NeMo-RL or Gym overlay mount (new branch, new commit). Without it, workers may use stale cached venvs with outdated code, causing import errors or silent behavior differences. Safe to leave enabled — only triggers a rebuild when the source tree actually changes | + #### API Keys (Secrets) | Variable | Purpose | How to Get | diff --git a/docs/development/console-ui.md b/docs/development/console-ui.md index 119699a..2a0761a 100644 --- a/docs/development/console-ui.md +++ b/docs/development/console-ui.md @@ -13,10 +13,10 @@ def execute(self, config, cluster, expname, run_after=None): # Show configuration console.detail("Model", config['model']) - console.detail("GPUs", str(config['num_gpus'])) + console.detail("GPUs", str(config['total_gpus'])) console.detail("Cluster", cluster) - # Submit job (via nemo-skills) + # Submit job (via NeMo-RL) job_id = submit_to_cluster(...) console.success(f"Job submitted: {job_id}") @@ -57,11 +57,11 @@ from pathlib import Path from typing import Any, Dict, List, Optional from nvflow.core import BaseStage, StageRegistry, console -@StageRegistry.register(recipe="finance", workflow="training_sft", stage="sft") +@StageRegistry.register(recipe="finance", workflow="sft", stage="sft") class SFTStage(BaseStage): """Supervised fine-tuning stage.""" - workflow = "training_sft" + workflow = "sft" def execute( self, @@ -79,7 +79,7 @@ class SFTStage(BaseStage): # Show configuration console.detail("Model", config['model']) console.detail("Data", config['data_path']) - console.detail("GPUs", str(config.get('num_gpus', 8))) + console.detail("GPUs", str(config.get('total_gpus', 8))) console.detail("Cluster", cluster) console.blank() diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md index e8dece4..0f49021 100644 --- a/docs/diagrams/README.md +++ b/docs/diagrams/README.md @@ -21,6 +21,7 @@ Complete data flow pipeline for the Finance recipe: - Stage 3: Data preparation for SFT - Stage 4: Supervised fine-tuning - Stage 5: Model evaluation +- Stage 6: GRPO RL training **Best for:** Understanding the end-to-end ML pipeline flow in the finance domain. diff --git a/docs/diagrams/architecture-overview.mmd b/docs/diagrams/architecture-overview.mmd index de32d65..746f28d 100644 --- a/docs/diagrams/architecture-overview.mmd +++ b/docs/diagrams/architecture-overview.mmd @@ -22,7 +22,7 @@ graph TB %% Recipe Layer subgraph Recipes["📦 Recipe Layer"] direction LR - Finance["Finance Recipe
27 stages, 6 workflows"] + Finance["Finance Recipe
42 stages, 6 workflows"] Example["Example Recipe
Learning & testing"] Custom["Custom Recipes
Domain-specific"] end diff --git a/docs/diagrams/component-architecture.mmd b/docs/diagrams/component-architecture.mmd index ff814f5..225d54a 100644 --- a/docs/diagrams/component-architecture.mmd +++ b/docs/diagrams/component-architecture.mmd @@ -66,7 +66,7 @@ classDiagram %% Concrete Stage Examples class SFTStage { - +workflow: "training_sft" + +workflow: "sft" +execute(config, cluster, expname, run_after) +validate_config(config) } @@ -127,9 +127,9 @@ classDiagram EvaluateStage ..> NemoSkills : delegates to %% Notes - note for BaseStage "All stages inherit from BaseStage\nand implement execute() method.\nStages are registered via decorator:\n@StageRegistry.register(\n recipe='finance',\n workflow='training_sft',\n stage='sft'\n)" + note for BaseStage "All stages inherit from BaseStage\nand implement execute() method.\nStages are registered via decorator:\n@StageRegistry.register(\n recipe='finance',\n workflow='sft',\n stage='sft'\n)" - note for StageRegistry "Hierarchical registry:\nRecipe → Workflow → Stage\n\nExample:\nfinance.training_sft.sft\n↓\nSFTStage class" + note for StageRegistry "Hierarchical registry:\nRecipe → Workflow → Stage\n\nExample:\nfinance.sft.sft\n↓\nSFTStage class" note for WorkflowRunner "Loads YAML configs with\ninheritance support (_base_ key)\nManages stage dependencies\nGenerates unique expnames" diff --git a/docs/recipes/finance/README.md b/docs/recipes/finance/README.md index af98cb7..15d557e 100644 --- a/docs/recipes/finance/README.md +++ b/docs/recipes/finance/README.md @@ -107,12 +107,14 @@ End-to-end pipeline for generating synthetic financial Q&A data from SEC filings │ ↓ ┌────────────────────────────────────────┐ - │ 4. GRPO RL Training (8 stages) │ + │ 4. GRPO RL Training (10 stages) │ ├────────────────────────────────────────┤ │ • Prepare data (agent routing) │ │ • Collect rollouts + reward analysis │ │ • [Optional] Re-compute rewards │ │ • GRPO training with NeMo-Gym │ + │ (equivalence_llm_judge, │ + │ finance_sec_search) │ │ • Eval (checkpoint + baseline) │ ├────────────────────────────────────────┤ │ Output: RL-trained model + eval results│ @@ -128,7 +130,7 @@ End-to-end pipeline for generating synthetic financial Q&A data from SEC filings | 3 | [document-grounded-sdg](workflows/03-document-grounded-sdg.md) | Generate verified Q&A from documents | 7 | 8 | | 4 | [sft](workflows/04-sft.md) | Supervised fine-tuning + checkpoint eval | 6 | 256 | | 5 | [eval](workflows/05-eval.md) | Baseline model evaluation | 7 | 8 | -| 6 | [grpo](workflows/06-grpo.md) | GRPO RL training + checkpoint eval | 8 | 8 | +| 6 | [grpo](workflows/06-grpo.md) | GRPO RL training + checkpoint eval | 10 | 16 | > **Note:** GPU counts show the maximum requirement for any single stage in the workflow (i.e., minimum GPUs needed to run the pipeline). @@ -171,7 +173,7 @@ Detailed technical specifications for each stage: - **[Document-Grounded SDG Stages](stages/document-grounded-sdg.md)** - 7 stages - **[SFT Stages](stages/sft.md)** - 6 stages - **[Eval Stages](stages/eval.md)** - 9 stages -- **[GRPO Stages](stages/grpo.md)** - 9 stages +- **[GRPO Stages](stages/grpo.md)** - 10 stages ## Quick Command Reference diff --git a/docs/recipes/finance/quick-start.md b/docs/recipes/finance/quick-start.md index 8e2a753..0e85fb5 100644 --- a/docs/recipes/finance/quick-start.md +++ b/docs/recipes/finance/quick-start.md @@ -12,7 +12,7 @@ Get hands-on experience with the finance recipe by running a complete end-to-end - [Step 2: Download SEC Filings](#step-2-download-sec-filings) — ~3 min - [Step 3: Generate Synthetic Q&A Data](#step-3-generate-synthetic-qa-data) — ~20 min - [Step 4: Fine-Tune Model + Evaluate (SFT)](#step-4-fine-tune-model--evaluate-sft) — ~25 min -- [Step 5: GRPO RL Training + Evaluate (Experimental)](#step-5-grpo-rl-training--evaluate) — ~1 hr +- [Step 5: GRPO RL Training + Evaluate](#step-5-grpo-rl-training--evaluate) — ~2 hr - [Next Steps](#next-steps) - [Troubleshooting](#troubleshooting) @@ -299,11 +299,11 @@ tail -f outputs/finance/demo/workflow-4-sft/qwen3_4b/step-4-training/model-qwen3 **Verify training:** ```bash -ls outputs/finance/demo/workflow-4-sft/qwen3_4b/step-4-training/model-qwen3-4b-1n-tp2-pp1-cp2-seq32k/checkpoints/ +ls outputs/finance/demo/workflow-4-sft/qwen3_4b/step-4-training/model-qwen3-4b-8g-tp2-pp1-cp2-seq32k/checkpoints/ # Expected: step_10/ step_16/ (checkpoint at save_period=10 and final epoch) -ls outputs/finance/demo/workflow-4-sft/qwen3_4b/step-4-training/model-qwen3-4b-1n-tp2-pp1-cp2-seq32k/final_hf_model/ -# Expected: HF-format model (safetensors, config.json, tokenizer files) +ls outputs/finance/demo/workflow-4-sft/qwen3_4b/step-4-training/model-qwen3-4b-8g-tp2-pp1-cp2-seq32k/hf_models/ +# Expected: step_10/ (HF-format model, converted during eval) ``` **Verify evaluation:** @@ -330,12 +330,12 @@ outputs/finance/demo/workflow-4-sft/qwen3_4b/ │ ├── train_bucket_*.jsonl # Grouped by sequence length │ └── logs/ ├── step-4-training/ -│ └── model-qwen3-4b-1n-tp2-pp1-cp2-seq32k/ +│ └── model-qwen3-4b-8g-tp2-pp1-cp2-seq32k/ │ ├── checkpoints/ │ │ ├── step_10/ # Megatron checkpoint (save_period=10) │ │ └── step_16/ # Final epoch checkpoint -│ ├── convert-final-ckpt/ # Megatron → HF conversion logs -│ ├── final_hf_model/ # HF-format safetensors (auto-converted) +│ ├── hf_models/ # HF-format models (converted during eval) +│ │ └── step_10/ # Per-step HF checkpoint │ └── training-logs/ └── step-5-eval/ └── step-10/ @@ -357,30 +357,41 @@ outputs/finance/demo/workflow-4-sft/qwen3_4b/
-

Step 5: GRPO RL Training + Evaluate — ~1 hr (Experimental)

- -> **Status: Experimental** — GRPO quality experiments are still in progress. Results may change as we refine reward signals and training hyperparameters. +

Step 5: GRPO RL Training + Evaluate — ~2 hr

> Config: `grpo/qwen3_4b.yaml` | Output: `outputs/finance/demo/workflow-5-grpo/qwen3_4b/` -Run GRPO reinforcement learning using LLM-as-judge rewards from the NeMo-Gym `equivalence_llm_judge` environment, then evaluate checkpoints on finance benchmarks. Uses the same Q&A pairs from Step 3 (`workflow-3-template-based-sdg/step-5-filter-answers/final_result.jsonl`) as training input — GRPO does **not** depend on the SFT checkpoint. Both rollout collection and training use a dedicated GPT-OSS-120B judge model (not the policy model) for accurate reward signals. +Run GRPO reinforcement learning using LLM-as-judge rewards from NeMo-Gym environments, then evaluate checkpoints on finance benchmarks. Uses the same Q&A pairs from Step 3 as training input — GRPO does **not** depend on the SFT checkpoint. + +**Two environments:** This demo trains on two independent NeMo-Gym environments, each producing a separate model: + +| Environment | Reward Signal | Context | +|-------------|--------------|---------| +| `equivalence_llm_judge` | LLM judges answer equivalence to gold | Question + SEC context provided | +| `finance_sec_search` | Multi-turn tool-calling agent retrieves SEC data | Agent must find context via tools | + +Each environment has its own data pipeline, rollout collection, and training. Use `-e ` to run a specific environment. **Preview stages:** ```bash uv run nflow list-stages --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml ``` -1. `data_transformation` — Clean raw SDG data to model-agnostic format (CPU) -2. `apply_prompt_template` — Apply prompt template + extract expected answer (CPU) -3. `convert_to_responses_api` — Convert to NeMo-Gym Responses API format (CPU) -4. `train_validation_split` — Split into train/val sets (CPU) +1. `validate_questions` — Filter ambiguous questions for finance_sec_search (GPU, GPT-OSS-120B) +2. `data_transformation` — Clean raw SDG data to model-agnostic format (CPU) +3. `apply_prompt_template` — Apply prompt template + extract expected answer (CPU) +4. `convert_to_responses_api` — Convert to NeMo-Gym Responses API format (CPU) 5. `prepare_data` — Add agent routing fields for NeMo-Gym (CPU) -6. `collect_rollouts` — Collect rollouts, profile rewards, and filter training data (GPU + CPU) -7. `training` — GRPO training with dedicated GPT-OSS-120B judge (8 + 4 GPUs, ~20 min) -8. `eval` — Evaluate checkpoint on finance benchmarks (GPU, ~6 min) +6. `prefetch_cache` — Pre-warm SEC metadata cache for finance_sec_search (CPU) +7. `collect_rollouts` — Collect rollouts, profile rewards, and filter training data (GPU) +8. `train_validation_split` — Split reward-filtered data into train/val (CPU) +9. `training` — GRPO training with NeMo-Gym environment (GPU, ~20 min per env) +10. `eval` — Evaluate checkpoint on finance benchmarks (GPU, ~6 min) + +Stages 1-8 run **per-environment**: outputs are written to `{step_dir}/{env_name}/`. -Stage 6 (`collect_rollouts`) includes automatic sub-jobs: -- **Rollout** (GPU) — policy + judge vLLM servers + NeMo-Gym client, per seed +Stage 7 (`collect_rollouts`) includes automatic sub-jobs: +- **Rollout** (GPU) — policy + judge vLLM servers + NeMo-Gym client, per seed (8 seeds) - **Merge + Analyze** (CPU) — merge chunks and compute per-seed reward distributions - **Aggregate** (CPU) — cross-seed pass@k metrics and `difficulty.jsonl` - **Filter** (CPU) — curate training data by removing too-hard and too-easy questions @@ -398,53 +409,89 @@ uv run nflow run prepare_data --config nvflow/recipes/finance/workflows/eval/dem **Run:** -We recommend running one stage at a time so you can inspect outputs and catch issues early, rather than using `run-all` which submits all stages and their Slurm dependencies at once: +We recommend running one environment at a time so you can inspect the full lifecycle before moving to the next: + +**Environment 1: `equivalence_llm_judge` (~45 min)** + +The simpler environment — LLM judges whether the model's answer is equivalent to the gold answer. Context is provided directly. + +```bash +# Data preparation (CPU, fast) +uv run nflow run data_transformation apply_prompt_template convert_to_responses_api prepare_data \ + --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e equivalence_llm_judge + +# Rollout collection (GPU, ~20 min) — inspect reward distributions before proceeding +uv run nflow run collect_rollouts --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e equivalence_llm_judge + +# Post-rollout train/val split (CPU) +uv run nflow run train_validation_split --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e equivalence_llm_judge + +# Training (FSDP v2, 16 GPUs, ~60 min) +uv run nflow run training --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e equivalence_llm_judge +``` + +**Environment 2: `finance_sec_search` (~1 hr)** + +The multi-turn tool-calling environment — the agent must retrieve SEC filings via tools before answering. Includes question validation and SEC cache prefetch. ```bash -# Data preparation (CPU stages, fast) -uv run nflow run data_transformation apply_prompt_template convert_to_responses_api train_validation_split prepare_data \ - --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml +# Question validation (GPU, uses GPT-OSS-120B judge — multi-job, wait for completion) +uv run nflow run validate_questions --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e finance_sec_search +# Wait for all validate_questions Slurm jobs to finish (check: squeue --me) -# Rollout collection (GPU, ~30 min) — inspect reward distributions before proceeding -uv run nflow run collect_rollouts --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml +# Data preparation + cache prefetch (CPU + GPU) +uv run nflow run data_transformation apply_prompt_template convert_to_responses_api prepare_data prefetch_cache \ + --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e finance_sec_search -# Training (GPU, ~20 min) -uv run nflow run training --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml +# Rollout collection (GPU, ~30 min) +uv run nflow run collect_rollouts --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e finance_sec_search -# Evaluation (GPU, ~6 min) -uv run nflow run eval --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml +# Post-rollout train/val split (CPU) +uv run nflow run train_validation_split --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e finance_sec_search + +# Training (Megatron, 64 GPUs — uses separate config for YaRN + CP=4) +uv run nflow run training --config nvflow/recipes/finance/workflows/grpo/qwen3_4b_finsec.yaml -e finance_sec_search ``` -Alternatively, to submit all stages at once with Slurm dependencies: +**Evaluation (~6 min each):** ```bash -uv run nflow run-all --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml +# Equivalence eval (FSDP checkpoint) +uv run nflow run eval --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e equivalence_llm_judge + +# Finance SEC search eval (Megatron checkpoint) +uv run nflow run eval --config nvflow/recipes/finance/workflows/grpo/qwen3_4b_finsec.yaml -e finance_sec_search ``` **Monitor:** ```bash squeue --me -# Rollout logs (one per seed) -tail -f outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/logs/*.log +# Rollout logs (one per seed per environment) +tail -f outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/*/logs/*.log # Training logs -tail -f outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-7-training/grpo-qwen3-4b-*/training-logs/ray-*-job.log +tail -f outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-7-training/*/grpo-qwen3-4b-*/training-logs/ray-*-job.log ``` -**Verify rollouts:** +**Verify rollouts (both environments):** ```bash -cat outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/rollout/analysis_rs0/summary.txt -cat outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/rollout/aggregate/summary.txt +# equivalence_llm_judge +cat outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/equivalence_llm_judge/rollout/aggregate/summary.txt +cat outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/equivalence_llm_judge/filter/filter_report.json +wc -l outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/equivalence_llm_judge/train.jsonl -wc -l outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/train.jsonl -cat outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/filter/filter_report.json +# finance_sec_search +cat outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/finance_sec_search/rollout/aggregate/summary.txt +cat outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/finance_sec_search/filter/filter_report.json +wc -l outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-5-collect-rollouts/finance_sec_search/train.jsonl ``` -**Verify training:** +**Verify training (per-environment):** ```bash -ls outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-7-training/grpo-qwen3-4b-*/checkpoints/ +# equivalence_llm_judge model +ls outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-8-training/equivalence_llm_judge/grpo-qwen3-4b-*/checkpoints/ # Expected: step_10/ step_20/ (save_period=10, max_num_steps=20) -ls outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-7-training/grpo-qwen3-4b-*/final_hf_model/ -# Expected: HF-format model (safetensors, config.json, tokenizer files) +# finance_sec_search model +ls outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-8-training/finance_sec_search/grpo-qwen3-4b-*/checkpoints/ ``` **Verify evaluation:** @@ -455,59 +502,59 @@ cat outputs/finance/demo/workflow-5-grpo/qwen3_4b/step-8-eval/step-20/eval-resul **Output:** ``` -outputs/finance/demo/workflow-5-grpo/qwen3_4b/ -├── step-0-data-transformation/ -│ ├── chunks/ # 10 chunked JSONL files -│ ├── filtered_outliers.jsonl -│ └── logs/ -├── step-1-apply-prompt-template/ -│ ├── final_result_chunk*.jsonl # 10 prompted chunks -│ └── logs/ -├── step-2-convert-to-responses-api/ -│ ├── final_result.jsonl # Responses API format -│ └── logs/ -├── step-3-train-validation-split/ -│ ├── train.jsonl # ~1060 training examples -│ ├── val.jsonl # ~120 validation examples -│ └── logs/ +outputs/finance/demo/workflow-5-grpo/ +├── step-0-validate-questions/ +│ └── finance_sec_search/ # Only finance_sec_search (equivalence skips validation) +│ └── final_result.jsonl +├── step-1-data-transformation/ +│ ├── equivalence_llm_judge/ +│ │ └── chunks/ +│ └── finance_sec_search/ +│ └── chunks/ +├── step-2-apply-prompt-template/ +│ ├── equivalence_llm_judge/ +│ └── finance_sec_search/ +├── step-3-convert-to-responses-api/ +│ ├── equivalence_llm_judge/ +│ └── finance_sec_search/ ├── step-4-prepare-data/ -│ ├── train.jsonl # With agent_ref routing fields -│ ├── validation.jsonl -│ ├── agent_config_overlay.yaml -│ └── logs/ -├── step-5-collect-rollouts/ -│ ├── rollout/ -│ │ ├── output-rs*.jsonl # 8 seed rollouts -│ │ ├── analysis_rs*/ # Per-seed reward analysis -│ │ │ └── summary.txt -│ │ └── aggregate/ # Cross-seed pass@k metrics -│ │ ├── difficulty.jsonl -│ │ └── summary.txt -│ ├── filter/ -│ │ └── filter_report.json # ~37% kept (too-hard/too-easy removed) -│ ├── train.jsonl # ~395 filtered training examples -│ ├── validation.jsonl -│ └── logs/ -├── step-7-training/ -│ └── grpo-qwen3-4b-*/ -│ ├── checkpoints/ -│ │ ├── step_10/ # Checkpoint (save_period=10) -│ │ └── step_20/ # Final checkpoint (max_num_steps=20) -│ ├── final_hf_model/ # HF-format model (auto-converted) -│ └── training-logs/ -└── step-8-eval/ - └── step-20/ - ├── eval-results/ - │ ├── secque/ - │ │ └── metrics.json - │ └── financebench/ - │ └── metrics.json - └── logs/ -``` - -> **Note:** Demo results will vary due to limited training data (7 companies) and rollout stochasticity. The filter stage typically keeps ~37% of questions (removing too-hard and too-easy), which provides the best RL training signal. - -> **Tip:** For production, use `grpo/qwen3_14b.yaml` for Qwen3-14B or create a custom model config inheriting from `grpo/base.yaml`. +│ ├── equivalence_llm_judge/ +│ │ ├── train.jsonl +│ │ └── agent_config_overlay.yaml +│ └── finance_sec_search/ +│ ├── train.jsonl +│ └── agent_config_overlay.yaml +├── qwen3_4b/ +│ ├── step-5-collect-rollouts/ +│ │ ├── equivalence_llm_judge/ +│ │ │ ├── rollout/aggregate/summary.txt +│ │ │ ├── filter/filter_report.json +│ │ │ └── train.jsonl +│ │ └── finance_sec_search/ +│ │ ├── rollout/aggregate/summary.txt +│ │ ├── filter/filter_report.json +│ │ └── train.jsonl +│ ├── step-8-training/ +│ │ ├── equivalence_llm_judge/ # Per-env model +│ │ │ └── grpo-qwen3-4b-*/ +│ │ │ ├── checkpoints/ +│ │ │ └── training-logs/ +│ │ └── finance_sec_search/ # Per-env model +│ │ └── grpo-qwen3-4b-*/ +│ │ ├── checkpoints/ +│ │ └── training-logs/ +│ └── step-9-eval/ +│ └── step-20/ +│ └── eval-results/ +│ ├── secque/metrics.json +│ └── financebench/metrics.json +``` + +> **Per-environment training:** Each environment produces a separate model checkpoint. To train a single combined model on both environments, omit `-e` in the training command. + +> **Note:** Demo results will vary due to limited training data (7 companies) and rollout stochasticity. The filter stage typically keeps 25-40% of questions (removing too-hard and too-easy), which provides the best RL training signal. + +> **Tip:** For production (Qwen3-30B-A3B on S&P 500 data), use `grpo/qwen3_30b_a3b.yaml`.
diff --git a/docs/recipes/finance/stages/eval.md b/docs/recipes/finance/stages/eval.md index ee42303..7b5ede7 100644 --- a/docs/recipes/finance/stages/eval.md +++ b/docs/recipes/finance/stages/eval.md @@ -94,7 +94,7 @@ stages: eval: eval_steps: [2600, 5000, 7408] checkpoint_path: ${directories.step-4-training}/model-name - format: megatron # Use "hf" for GRPO checkpoints + format: megatron # Use "fsdp" for GRPO demo, "megatron" for GRPO production baseline_model: /hf_models/Qwen/Qwen3-14B server_type: vllm gpus: 1 @@ -110,7 +110,7 @@ stages: |-----------|------|-------------| | `eval_steps` | list[int] | Training steps to evaluate | | `checkpoint_path` | path | Base path to training checkpoints | -| `format` | str | `"megatron"` (SFT) or `"hf"` (GRPO) | +| `format` | str | `"megatron"` (SFT), `"fsdp"` (GRPO demo), or `"megatron"` (GRPO production) | | `baseline_model` | path | HF model path for baseline comparison | | `server_type` | str | Inference server: `"vllm"`, `"openai"` | | `gpus` | int | GPUs for model server | diff --git a/docs/recipes/finance/stages/finance-agent-eval.md b/docs/recipes/finance/stages/finance-agent-eval.md index 9348076..7c0d2fd 100644 --- a/docs/recipes/finance/stages/finance-agent-eval.md +++ b/docs/recipes/finance/stages/finance-agent-eval.md @@ -1,5 +1,7 @@ # Finance Agent Eval Stages Reference +> **Status:** finance_agent evaluation is currently disabled in `eval/base.yaml` pending further validation. The configuration below is preserved for re-enablement. + Technical reference for the finance-agent evaluation stages (vals-ai/finance-agent benchmark). > **Note:** Finance agent evaluation is now integrated into the main eval workflow. The `finance_agent` benchmark is defined in `workflows/eval/base.yaml` and runs alongside SEC-QUE and FinanceBench. See [Eval Workflow](../workflows/05-eval.md) for usage. diff --git a/docs/recipes/finance/stages/grpo.md b/docs/recipes/finance/stages/grpo.md index 7a6e23d..f22bd34 100644 --- a/docs/recipes/finance/stages/grpo.md +++ b/docs/recipes/finance/stages/grpo.md @@ -1,6 +1,6 @@ # GRPO Stages Reference -Technical reference for all 9 stages in the GRPO RL training workflow (8 active + 1 optional). +Technical reference for all 10 stages in the GRPO RL training workflow (9 active + 1 optional). ## Quick Navigation @@ -232,6 +232,8 @@ Collect model rollouts against a NeMo-Gym environment with reward scoring. Suppo | `num_chunks` | int | Split input into N parallel jobs | `1` | | `num_random_seeds` | int | Independent runs per chunk | `1` | | `starting_seed` | int | First seed value | `0` | +| `dependent_jobs` | int | Chain N+1 Slurm jobs per chunk via `afterany` for timeout recovery | `0` | +| `responses_create_params` | dict | Pass-through params for NeMo-Gym (e.g., `max_output_tokens`) | `{}` | | `rerun_done` | bool | Force re-execution | `false` | | `num_gpus` | int | GPUs per Slurm job | `8` | | `tensor_parallel_size` | int | Policy vLLM TP | `2` | @@ -252,7 +254,7 @@ Collect model rollouts against a NeMo-Gym environment with reward scoring. Suppo ### Execution Model ``` -Total Slurm jobs = num_chunks × num_random_seeds +Total Slurm jobs = num_chunks × num_random_seeds × (dependent_jobs + 1) ``` Each job is self-contained: @@ -263,7 +265,7 @@ Each job is self-contained: 5. Write `.done` file on completion After all chunk jobs complete, a merge job per seed: -1. Concatenates chunk files → `rollouts-rs{seed}.jsonl` +1. Concatenates chunk files → `output-rs{seed}.jsonl` 2. Enriches rollouts with input metadata (uuid, question, etc.) 3. Runs reward analysis (distribution, verdicts, difficulty) 4. Deletes individual chunk files @@ -276,20 +278,33 @@ After all chunk jobs complete, a merge job per seed: - Reward distribution (correct / incorrect / partial) - Judge verdict breakdown - RL signal assessment (warns if rewards are too uniform) -- Difficulty analysis (per-question pass rates when `num_repeats > 1`) +- Difficulty analysis (per-question `reward_std`, `reward_min`, `reward_max` when `num_repeats > 1`) + +**Aggregation** (`aggregate_seeds.py`): When multiple random seeds are used, aggregates per-question statistics across seeds to compute cross-seed `reward_std` and `pass@k` metrics. Output is written to the `aggregate/` subdirectory. + +**Filtering** (`filter_training_data.py`): Filters the training data based on difficulty metrics (e.g., `min_reward_std`) to remove questions that are too easy (all correct) or too hard (all incorrect), keeping only questions with meaningful reward variance for RL training. + +### Resume and Robustness + +- **`.done` markers**: Each chunk writes a `.done` file on successful completion. Subsequent runs (including `dependent_jobs` chains) skip completed chunks automatically. +- **`.prev` backup**: Before merging, the previous merged output is saved as `.prev` to prevent data loss if the merge job is interrupted. +- **Self-heal**: If a chunk job is interrupted mid-write, the next job in the `dependent_jobs` chain detects incomplete files and re-runs from the last checkpoint. +- **`dependent_jobs` retry**: Jobs are chained via Slurm `afterany`, so the next job runs regardless of how the previous one exited (success, timeout, or failure). ### Outputs ``` ${output_dir}/ -├── rollouts-rs0.jsonl # Merged, enriched rollouts -├── analysis_rs0/ -│ ├── summary.txt # Human-readable analysis -│ ├── correct.jsonl # Samples with reward == 1.0 -│ ├── incorrect.jsonl # Samples with reward == 0.0 -│ ├── partial.jsonl # Samples with 0 < reward < 1 -│ ├── judge_failed.jsonl # Samples with no judge evaluations -│ └── difficulty.jsonl # Per-question pass rates (if repeats) +├── rollout/ +│ ├── output-rs0.jsonl # Merged, enriched rollouts +│ ├── analysis_rs0/ +│ │ ├── summary.txt # Human-readable analysis +│ │ ├── best.jsonl # Samples with highest reward +│ │ ├── worst.jsonl # Samples with lowest reward +│ │ ├── partial.jsonl # Samples with 0 < reward < 1 +│ │ ├── judge_failed.jsonl # Samples with no judge evaluations +│ │ └── difficulty.jsonl # Per-question reward_std, reward_min, reward_max +│ └── aggregate/ # Cross-seed aggregation (reward_std, pass@k) ├── scripts/ # Generated Slurm scripts └── logs/ # vLLM, ng_run, and merge logs ``` @@ -365,8 +380,7 @@ Run GRPO reinforcement learning using NeMo-RL with online NeMo-Gym environment r | `hf_checkpoint_path` | path | Model weights path | Required | | `preset` | string | GRPO preset name from `grpo_presets.yaml` | Required | | `backend` | string | `"fsdp"` or `"megatron"` | `"fsdp"` | -| `num_nodes` | int | Number of nodes | `1` | -| `num_gpus` | int | GPUs per node | `8` | +| `total_gpus` | int | Total GPUs for training (auto-split across nodes) | `16` (demo) / `64` (production) | | `dependent_jobs` | int | Multi-job chaining for long runs | `0` | | `training_data` | path | Training JSONL (from prepare_data) | Required | | `validation_data` | path | Validation JSONL | Required | @@ -417,7 +431,7 @@ ${output_dir}/grpo-{model}-{nodes}n-tp{tp}-cp{cp}-seq{seq}k/ | Model Size | GPUs | Runtime (demo) | |------------|------|----------------| -| 4B | 8 (1 node) | ~20 min | +| 4B | 16 (2 nodes) | ~20 min | | 14B | 64 (8 nodes) | TBD | --- @@ -440,7 +454,7 @@ Also registered for the SFT workflow, making it a shared evaluation stage across | `eval_output_dir` | path | Output directory for evaluation results | Required | | `eval_steps` | list | Checkpoint steps to evaluate | `[]` | | `checkpoint_path` | path | Path to training checkpoints | Required | -| `format` | string | Checkpoint format: `"hf"`, `"fsdp"`, `"megatron"` | `"megatron"` | +| `format` | string | Checkpoint format: `"hf"`, `"fsdp"`, `"megatron"` | `"fsdp"` (demo) / `"megatron"` (production) | | `baseline_model` | path | Baseline model for comparison evaluation | Optional | | `server_type` | string | Inference server type | `"vllm"` | | `gpus` | int | GPUs for inference server | `1` | diff --git a/docs/recipes/finance/stages/sft.md b/docs/recipes/finance/stages/sft.md index 0af6900..312d5fe 100644 --- a/docs/recipes/finance/stages/sft.md +++ b/docs/recipes/finance/stages/sft.md @@ -411,7 +411,7 @@ Convert Qwen3 chat-templated training data to OpenAI messages format. Parses Qwe ### Batch Size -Effective batch size = `per_device_train_batch_size` × `gradient_accumulation_steps` × `num_gpus` +Effective batch size = `per_device_train_batch_size` × `gradient_accumulation_steps` × `total_gpus` Recommended: 32-128 for most models diff --git a/docs/recipes/finance/troubleshooting.md b/docs/recipes/finance/troubleshooting.md index e6da1aa..5b66396 100644 --- a/docs/recipes/finance/troubleshooting.md +++ b/docs/recipes/finance/troubleshooting.md @@ -164,9 +164,9 @@ rm -rf /workspace/outputs/old_runs/ # Check available GPUs sinfo -p your_partition -# Adjust num_nodes in config -# Example: Use 16 GPUs instead of 32 -num_nodes: 2 # 16 GPUs (2 nodes × 8 GPUs) +# Adjust total_gpus in config +# Example: Use 16 GPUs instead of 256 +total_gpus: 16 ``` --- @@ -218,13 +218,13 @@ sec_identity_company: "Your Company Name" **Solutions:** ```bash # Validate JSONL format -python -c "import jsonlines; list(jsonlines.open('file.jsonl'))" +uv run python -c "import jsonlines; list(jsonlines.open('file.jsonl'))" # Check for empty files wc -l output.jsonl # Inspect sample records -head -1 output.jsonl | python -m json.tool +head -1 output.jsonl | uv run python -m json.tool ``` --- @@ -259,7 +259,7 @@ Check for training logs in any file of the form `ray--job.log` in the out wc -l outputs/finance/sap-500/workflow-4-sft/qwen3_14b/step-2-train-validation-split/train.jsonl # Inspect sample -head -1 outputs/finance/sap-500/workflow-4-sft/qwen3_14b/step-2-train-validation-split/train.jsonl | python -m json.tool +head -1 outputs/finance/sap-500/workflow-4-sft/qwen3_14b/step-2-train-validation-split/train.jsonl | uv run python -m json.tool ``` **Adjust hyperparameters:** diff --git a/docs/recipes/finance/workflows/01-download-sec.md b/docs/recipes/finance/workflows/01-download-sec.md index 3325129..a10a8b0 100644 --- a/docs/recipes/finance/workflows/01-download-sec.md +++ b/docs/recipes/finance/workflows/01-download-sec.md @@ -44,8 +44,8 @@ The NVFlow stage handles cluster integration and output management while the bun ## Configuration Files -- **Demo:** `configs/demo.yaml` - 7 companies (NVDA, AAPL, GOOG, MSFT, CSCO, META, IBM) -- **Production:** `configs/sp500.yaml` - 500+ S&P 500 companies +- **Demo:** `nvflow/recipes/finance/configs/demo.yaml` - 7 companies (NVDA, AAPL, GOOG, MSFT, CSCO, META, IBM) +- **Production:** `nvflow/recipes/finance/configs/sp500.yaml` - 500+ S&P 500 companies ## Workflow Overview @@ -152,7 +152,7 @@ ls outputs/finance/demo/workflow-2-download-sec/step-0-download/data/ | wc -l # Demo: 7, Production: 500+ # Check metadata -python -c "import pandas as pd; df = pd.read_parquet('outputs/finance/demo/workflow-2-download-sec/step-0-download/sec_metadata.parquet'); print(f'Total filings: {len(df[df.file_type ==\"primary_document\"])}')" +uv run python -c "import pandas as pd; df = pd.read_parquet('outputs/finance/demo/workflow-2-download-sec/step-0-download/sec_metadata.parquet'); print(f'Total filings: {len(df[df.file_type ==\"primary_document\"])}')" # Check storage used du -sh outputs/finance/demo/workflow-2-download-sec/step-0-download/ diff --git a/docs/recipes/finance/workflows/04-sft.md b/docs/recipes/finance/workflows/04-sft.md index 7c1e14c..3aa39c4 100644 --- a/docs/recipes/finance/workflows/04-sft.md +++ b/docs/recipes/finance/workflows/04-sft.md @@ -182,7 +182,7 @@ outputs/finance/sap-500/workflow-4-sft/qwen3_14b/ ├── step-3-sequence-length-grouping/ # [Optional] │ └── grouped_data/ ├── step-4-training/ -│ └── model-qwen3-14b-32n-tp4-pp1-cp8-seq48k/ +│ └── model-qwen3-14b-256g-tp4-pp1-cp8-seq48k/ │ ├── checkpoints/ │ │ ├── checkpoint-100/ │ │ ├── checkpoint-200/ @@ -197,7 +197,7 @@ outputs/finance/sap-500/workflow-4-sft/qwen3_14b/ | Model | Training Samples | Validation Samples | Training Duration | Final Checkpoint | |-------|------------------|-------------------|-------------------|------------------| -| Qwen3-14B (32 nodes, 256 GPUs) | ~330K | ~37K | 12-16 hours (4 jobs) | `outputs/finance/sap-500/workflow-4-sft/qwen3_14b/step-4-training/.../checkpoints/final/` | +| Qwen3-14B (256 GPUs) | ~330K | ~37K | 12-16 hours (4 jobs) | `outputs/finance/sap-500/workflow-4-sft/qwen3_14b/step-4-training/.../checkpoints/final/` | **Note:** Training runs as 4 sequential jobs (~4 hours each max). Total time depends on cluster availability and whether the final job finishes early. @@ -215,10 +215,10 @@ wc -l $OUTPUT_DIR/step-2-train-validation-split/val.jsonl # Should be ~37K wc -l $OUTPUT_DIR/step-1-prepare-for-sft/final_result.jsonl # Should be ~366K # List checkpoints -ls $OUTPUT_DIR/step-4-training/model-qwen3-14b-32n-tp4-pp1-cp8-seq48k/checkpoints/ +ls $OUTPUT_DIR/step-4-training/model-qwen3-14b-256g-tp4-pp1-cp8-seq48k/checkpoints/ # Check final model -ls $OUTPUT_DIR/step-4-training/model-qwen3-14b-32n-tp4-pp1-cp8-seq48k/checkpoints/final/ +ls $OUTPUT_DIR/step-4-training/model-qwen3-14b-256g-tp4-pp1-cp8-seq48k/checkpoints/final/ # Should contain: config.json, model weights, tokenizer files ``` @@ -258,7 +258,7 @@ stages: training: model_name: MyOrg/MyModel hf_checkpoint_path: /hf_models/MyOrg/MyModel - num_nodes: 8 # Adjust for your cluster + total_gpus: 64 # Adjust for your model size # Use existing preset or create custom preset: "qwen-3-14b" # Or your custom preset @@ -282,7 +282,7 @@ stages: **Key configuration points:** 1. Update `tokenizer` path in `prepare_for_sft` (Step 1) and `sequence_length_grouping` (Step 3) 2. Set `model_name` and `hf_checkpoint_path` to your model in `training` (Step 4) -3. Adjust `num_nodes` based on model size and available resources +3. Adjust `total_gpus` based on model size and available resources 4. Tune `parallelism` settings for your model architecture ### Adjusting Training Parameters @@ -292,7 +292,7 @@ You can modify training behavior without changing the model. Common adjustments: ```yaml training: # === Resource Configuration === - num_nodes: 16 # Reduce for smaller runs (vs 32 in production) + total_gpus: 128 # Reduce for smaller runs (vs 256 in production) dependent_jobs: 1 # Fewer job splits (vs 3 in production) overrides: @@ -315,7 +315,7 @@ training: ``` **Parameter guide:** -- **`num_nodes`**: Total GPU resources (e.g., 32 nodes × 8 GPUs = 256 GPUs) +- **`total_gpus`**: Total GPUs for training (auto-split across nodes using `gpus_per_node` from cluster config) - **`dependent_jobs`**: Training split count (higher = more job restarts, lower max time per job) - **`max_num_epochs`**: Total training passes through the dataset - **`warmup_steps`**: Learning rate warmup (typically 5-10% of total steps) @@ -443,10 +443,10 @@ wandb_mode: disabled # online | offline | disabled ```bash # Count completed checkpoints -ls ${OUTPUT_DIR}/step-4-training/model-qwen3-14b-32n-tp4-pp1-cp8-seq48k/checkpoints/ | grep checkpoint | wc -l +ls ${OUTPUT_DIR}/step-4-training/model-qwen3-14b-256g-tp4-pp1-cp8-seq48k/checkpoints/ | grep checkpoint | wc -l # View latest checkpoint -ls -lht ${OUTPUT_DIR}/step-4-training/model-qwen3-14b-32n-tp4-pp1-cp8-seq48k/checkpoints/ | head +ls -lht ${OUTPUT_DIR}/step-4-training/model-qwen3-14b-256g-tp4-pp1-cp8-seq48k/checkpoints/ | head ``` ### Troubleshooting diff --git a/docs/recipes/finance/workflows/05-eval.md b/docs/recipes/finance/workflows/05-eval.md index 3685ead..8584e2f 100644 --- a/docs/recipes/finance/workflows/05-eval.md +++ b/docs/recipes/finance/workflows/05-eval.md @@ -105,7 +105,7 @@ stages: eval: eval_steps: [1000, 3000, 5000] checkpoint_path: ${directories.step-4-training}/model-name - format: megatron # Use "hf" for GRPO checkpoints + format: megatron # Use "fsdp" for GRPO demo, "megatron" for GRPO production baseline_model: /hf_models/Qwen/Qwen3-14B server_type: vllm gpus: 1 @@ -170,7 +170,7 @@ stages: eval: eval_steps: [100, 500, 1000] checkpoint_path: ${directories.step-4-training}/model-my-model-name - format: megatron + format: megatron # Use "fsdp" for GRPO demo checkpoints baseline_model: /hf_models/MyOrg/MyModel server_type: vllm gpus: 1 diff --git a/docs/recipes/finance/workflows/06-grpo.md b/docs/recipes/finance/workflows/06-grpo.md index 153d8c1..7e263d9 100644 --- a/docs/recipes/finance/workflows/06-grpo.md +++ b/docs/recipes/finance/workflows/06-grpo.md @@ -21,7 +21,7 @@ Further improve fine-tuned models using Group Relative Policy Optimization (GRPO - ✅ Base model or SFT checkpoint accessible on cluster (Qwen3-4B for demo) - ✅ NeMo-RL container with NeMo-Gym (`nemo-rl` container) -- ✅ GPU resources (8 GPUs / 1 node for demo) +- ✅ GPU resources (16 GPUs / 2 nodes for demo, 64 GPUs / 8 nodes for production) ## Workflow Overview @@ -29,22 +29,22 @@ Further improve fine-tuned models using Group Relative Policy Optimization (GRPO ``` ┌──────────────────────────────┐ -│ 0. data_transformation │ SDG cleanup → model-agnostic schema (CPU) +│ 0. validate_questions │ Validate format + deduplicate (CPU) └───────────┬──────────────────┘ │ ▼ ┌──────────────────────────────┐ -│ 1. apply_prompt_template │ Apply prompt template + extract answer (CPU) +│ 1. data_transformation │ SDG cleanup → model-agnostic schema (CPU) └───────────┬──────────────────┘ │ ▼ ┌──────────────────────────────┐ -│ 2. convert_to_responses_api │ Convert to NeMo-Gym Responses API format (CPU) +│ 2. apply_prompt_template │ Apply prompt template + extract answer (CPU) └───────────┬──────────────────┘ │ ▼ ┌──────────────────────────────┐ -│ 3. train_validation_split │ Split into train/val sets (CPU) +│ 3. convert_to_responses_api │ Convert to NeMo-Gym Responses API format (CPU) └───────────┬──────────────────┘ │ ▼ @@ -54,35 +54,43 @@ Further improve fine-tuned models using Group Relative Policy Optimization (GRPO │ ▼ ┌──────────────────────────────┐ -│ 5. collect_rollouts │ Rollout collection + reward profiling + filter (GPU) +│ 5. prefetch_cache │ Prefetch SEC filings cache (CPU/Network) └───────────┬──────────────────┘ │ ▼ ┌──────────────────────────────┐ -│ 6. compute_rewards │ [Optional] Re-judge with different model (GPU/CPU) +│ 6. collect_rollouts │ Rollout collection + reward profiling + filter (GPU) └───────────┬──────────────────┘ │ ▼ ┌──────────────────────────────┐ -│ 7. training │ GRPO training with NeMo-Gym environment (GPU) +│ 7. train_validation_split │ Split into train/val sets (CPU) └───────────┬──────────────────┘ │ ▼ ┌──────────────────────────────┐ -│ 8. eval │ Evaluate checkpoints on finance benchmarks (GPU) +│ 8. training │ GRPO training with NeMo-Gym environment (GPU) +└───────────┬──────────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ 9. eval │ Evaluate checkpoints on finance benchmarks (GPU) └──────────────────────────────┘ ``` -**9 Stages (8 active + 1 optional):** -1. **data_transformation** (Step 0): Normalize raw SDG data to model-agnostic schema (shared with SFT pipeline) -2. **apply_prompt_template** (Step 1): Format the problem field using a prompt template and extract the concise expected answer -3. **convert_to_responses_api** (Step 2): Convert prompted data to NeMo-Gym Responses API format (lossless) -4. **train_validation_split** (Step 3): Split data into train/val sets with stratified sampling (shared with SFT pipeline) +**10 Stages (9 active + 1 optional):** +1. **validate_questions** (Step 0): Validate input questions for format compliance and deduplication +2. **data_transformation** (Step 1): Normalize raw SDG data to model-agnostic schema (shared with SFT pipeline) +3. **apply_prompt_template** (Step 2): Format the problem field using a prompt template and extract the concise expected answer +4. **convert_to_responses_api** (Step 3): Convert prompted data to NeMo-Gym Responses API format (lossless) 5. **prepare_data** (Step 4): Run `ng_prepare_data` to stamp JSONL records with agent routing fields for NeMo-Gym -6. **collect_rollouts** (Step 5): Collect model rollouts with reward scoring — includes enrichment (restore metadata), analysis (reward distribution, difficulty), and filtering -7. **compute_rewards** (Step 6, optional): Re-judge existing rollouts with a different/stronger judge model without re-generating responses -8. **training** (Step 7): GRPO training using NeMo-RL with online NeMo-Gym environment rewards -9. **eval** (Step 8): Evaluate GRPO checkpoints on finance benchmarks +6. **prefetch_cache** (Step 5): Prefetch SEC filings cache for finance_sec_search environment +7. **collect_rollouts** (Step 6): Collect model rollouts with reward scoring — includes enrichment (restore metadata), analysis (reward distribution, difficulty), and filtering +8. **train_validation_split** (Step 7): Split data into train/val sets with stratified sampling (shared with SFT pipeline) +9. **training** (Step 8): GRPO training using NeMo-RL with online NeMo-Gym environment rewards +10. **eval** (Step 9): Evaluate GRPO checkpoints on finance benchmarks + +> **Optional:** **compute_rewards** — Re-judge existing rollouts with a different/stronger judge model without re-generating responses **See [technical reference](../stages/grpo.md) for detailed stage documentation.** @@ -90,9 +98,8 @@ Further improve fine-tuned models using Group Relative Policy Optimization (GRPO | Config | Model | GPUs | Status | |--------|-------|------|--------| -| `grpo/qwen3_4b.yaml` | Qwen3-4B | 8 (1 node) | Demo | - -> **Note:** Additional model configs (Qwen3-14B, Nemotron) will be added as GRPO training is validated at scale. +| `grpo/qwen3_4b.yaml` | Qwen3-4B | 16 (2 nodes) | Demo | +| `grpo/qwen3_30b_a3b.yaml` | Qwen3-30B-A3B (MoE) | 64 (8 nodes) | Production | ## Usage @@ -108,28 +115,34 @@ uv run nflow run-all --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yam ```bash CONFIG=nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -# Step 0: SDG cleanup → model-agnostic schema (CPU) +# Step 0: Validate + deduplicate questions (GPU) +uv run nflow run validate_questions --config $CONFIG + +# Step 1: SDG cleanup → model-agnostic schema (CPU) uv run nflow run data_transformation --config $CONFIG -# Step 1: Apply prompt template + extract expected answer (CPU) +# Step 2: Apply prompt template + extract expected answer (CPU) uv run nflow run apply_prompt_template --config $CONFIG -# Step 2: Convert to NeMo-Gym Responses API format (CPU) +# Step 3: Convert to NeMo-Gym Responses API format (CPU) uv run nflow run convert_to_responses_api --config $CONFIG -# Step 3: Split into train/val sets (CPU) -uv run nflow run train_validation_split --config $CONFIG - # Step 4: Prepare data — add agent routing fields (CPU) uv run nflow run prepare_data --config $CONFIG +# Prefetch SEC filings cache (CPU/Network) +uv run nflow run prefetch_cache --config $CONFIG + # Step 5: Collect rollouts (inference + reward scoring + filter) (GPU) uv run nflow run collect_rollouts --config $CONFIG -# Step 7: GRPO training (GPU) +# Step 7: Split into train/val sets (CPU) +uv run nflow run train_validation_split --config $CONFIG + +# Step 8: GRPO training (GPU) uv run nflow run training --config $CONFIG -# Step 8: Evaluate checkpoints (GPU) +# Step 9: Evaluate checkpoints (GPU) uv run nflow run eval --config $CONFIG ``` @@ -140,13 +153,15 @@ To re-judge rollouts with a different judge model, enable `compute_rewards` in ` ```yaml # In your model config pipeline_stages: + - validate_questions - data_transformation - apply_prompt_template - convert_to_responses_api - - train_validation_split - prepare_data + - prefetch_cache - collect_rollouts - compute_rewards # Uncomment to enable + - train_validation_split - training - eval @@ -164,46 +179,52 @@ stages: ### Output Structure ``` -outputs/finance/demo/workflow-5-grpo/qwen3_4b/ -├── step-0-data-transformation/ +outputs/finance/demo/workflow-5-grpo/ +├── step-0-validate-questions/ +│ └── {env_name}/ # Validated + deduplicated questions +├── step-1-data-transformation/ │ ├── final_result.jsonl # Normalized SDG data (model-agnostic schema) │ ├── chunks/ # Chunked input for parallel processing │ └── logs/ -├── step-1-apply-prompt-template/ +├── step-2-apply-prompt-template/ │ ├── *.jsonl # Prompted data with extracted answers │ └── logs/ -├── step-2-convert-to-responses-api/ +├── step-3-convert-to-responses-api/ │ ├── final_result.jsonl # Data in Responses API format │ └── logs/ -├── step-3-train-validation-split/ -│ ├── train.jsonl # Training split -│ ├── val.jsonl # Validation split -│ └── logs/ ├── step-4-prepare-data/ │ ├── train.jsonl # Training data with agent_ref │ ├── validation.jsonl # Validation data with agent_ref │ └── agent_config_overlay.yaml # Auto-generated agent config -├── step-5-collect-rollouts/ -│ ├── rollouts-rs0.jsonl # Merged rollouts (enriched) -│ ├── train.jsonl # Filtered training data (from filter sub-job) -│ ├── validation.jsonl # Filtered validation data -│ ├── analysis_rs0/ -│ │ ├── summary.txt # Reward distribution report -│ │ ├── correct.jsonl # Samples with reward == 1.0 -│ │ ├── incorrect.jsonl # Samples with reward == 0.0 -│ │ └── difficulty.jsonl # Per-question pass rates (if repeats) -│ ├── scripts/ # Generated Slurm scripts -│ └── logs/ # vLLM and ng_run logs -├── step-6-compute-rewards/ # (only if compute_rewards enabled) -│ ├── train.jsonl # Re-judged + filtered training data -│ ├── validation.jsonl # Re-judged + filtered validation data -│ └── ... -├── step-7-training/ -│ └── grpo-qwen3-4b-1n-tp2-cp1-seq32k/ -│ ├── checkpoints/ # GRPO model checkpoints -│ └── training-logs/ -└── step-8-eval/ - └── ... # Benchmark evaluation results +└── qwen3_4b/ # Model-specific outputs + ├── step-5-collect-rollouts/ + │ ├── {env_name}/ # Per-environment subdirectory + │ │ ├── rollout/ + │ │ │ ├── output-rs0.jsonl # Merged rollouts (enriched) + │ │ │ ├── analysis_rs0/ + │ │ │ │ ├── summary.txt # Reward distribution report + │ │ │ │ ├── best.jsonl # Samples with highest reward + │ │ │ │ ├── worst.jsonl # Samples with lowest reward + │ │ │ │ └── difficulty.jsonl # Per-question reward_std, reward_min, reward_max + │ │ │ └── aggregate/ # Cross-seed aggregation (reward_std, pass@k) + │ │ ├── scripts/ # Generated Slurm scripts + │ │ └── logs/ # vLLM and ng_run logs + │ ├── train.jsonl # Filtered training data (from filter sub-job) + │ └── validation.jsonl # Filtered validation data + ├── step-6-compute-rewards/ # (only if compute_rewards enabled) + │ ├── train.jsonl # Re-judged + filtered training data + │ ├── validation.jsonl # Re-judged + filtered validation data + │ └── ... + ├── step-7-train-validation-split/ + │ ├── train.jsonl # Training split + │ ├── val.jsonl # Validation split + │ └── logs/ + ├── step-8-training/ + │ └── grpo-qwen3-4b-2n-tp2-cp4-seq131k/ # Demo (FSDP v2) + │ ├── checkpoints/ # GRPO model checkpoints + │ └── training-logs/ + └── step-9-eval/ + └── ... # Benchmark evaluation results ``` ### Expected Results (Demo) @@ -215,38 +236,39 @@ outputs/finance/demo/workflow-5-grpo/qwen3_4b/ | convert_to_responses_api | `final_result.jsonl` in Responses API format | CPU-only, ~1 min | | train_validation_split | `train.jsonl` + `val.jsonl` | CPU-only, ~1 min | | prepare_data | `train.jsonl` + `validation.jsonl` with agent_ref fields | CPU-only, ~1 min | -| collect_rollouts | `rollouts-rs0.jsonl` + analysis + filtered train/val | GPU inference, ~10 min | +| collect_rollouts | `output-rs0.jsonl` + analysis + filtered train/val | GPU inference, ~10 min | | training | GRPO checkpoint | GPU training, ~20 min | | eval | Benchmark scores | GPU inference, ~10 min | ### Validation ```bash -OUTPUT_DIR="outputs/finance/demo/workflow-5-grpo/qwen3_4b" +BASE_DIR="outputs/finance/demo/workflow-5-grpo" +MODEL_DIR="$BASE_DIR/qwen3_4b" -# Check normalized SDG data (Step 0) -head -1 $OUTPUT_DIR/step-0-data-transformation/final_result.jsonl | jq 'keys' +# Check normalized SDG data (Step 1) +head -1 $BASE_DIR/step-1-data-transformation/final_result.jsonl | jq 'keys' -# Check prompted data (Step 1) -head -1 $OUTPUT_DIR/step-1-apply-prompt-template/*.jsonl | jq '.problem' | head -c 200 +# Check prompted data (Step 2) +head -1 $BASE_DIR/step-2-apply-prompt-template/*.jsonl | jq '.problem' | head -c 200 -# Check Responses API conversion (Step 2) -head -1 $OUTPUT_DIR/step-2-convert-to-responses-api/final_result.jsonl | jq 'keys' - -# Check train/val split (Step 3) -wc -l $OUTPUT_DIR/step-3-train-validation-split/train.jsonl $OUTPUT_DIR/step-3-train-validation-split/val.jsonl +# Check Responses API conversion (Step 3) +head -1 $BASE_DIR/step-3-convert-to-responses-api/final_result.jsonl | jq 'keys' # Check prepared data with agent_ref (Step 4) -head -1 $OUTPUT_DIR/step-4-prepare-data/train.jsonl | jq '.agent_ref' +head -1 $BASE_DIR/step-4-prepare-data/train.jsonl | jq '.agent_ref' + +# Check rollout analysis (Step 5 — replace {env_name} with your environment) +cat $MODEL_DIR/step-5-collect-rollouts/{env_name}/rollout/analysis_rs0/summary.txt -# Check rollout analysis (Step 5) -cat $OUTPUT_DIR/step-5-collect-rollouts/analysis_rs0/summary.txt +# Check train/val split (Step 7) +wc -l $MODEL_DIR/step-7-train-validation-split/train.jsonl $MODEL_DIR/step-7-train-validation-split/val.jsonl -# Check training checkpoint (Step 7) -ls $OUTPUT_DIR/step-7-training/grpo-*/checkpoints/ +# Check training checkpoint (Step 8) +ls $MODEL_DIR/step-8-training/grpo-*/checkpoints/ -# Check eval results (Step 8) -ls $OUTPUT_DIR/step-8-eval/ +# Check eval results (Step 9) +ls $MODEL_DIR/step-9-eval/ ``` ## Customization @@ -278,13 +300,18 @@ For large-scale rollout collection (300K+ samples): ```yaml stages: collect_rollouts: - num_chunks: 16 # Split input into 16 parallel Slurm jobs - num_random_seeds: 5 # 5 independent runs for diversity - num_repeats: 5 # 5 repeats per sample (for pass_rate analysis) - num_samples_in_parallel: 8 # Concurrent requests per job + num_chunks: 8 # Split input into 8 parallel Slurm jobs + num_random_seeds: 1 # Independent runs per chunk + num_repeats: 5 # 5 repeats per sample (for variance-based difficulty filtering) + num_samples_in_parallel: 512 # Concurrent requests per job + dependent_jobs: 2 # Chain 3 Slurm jobs per chunk (afterany) for timeout recovery rerun_done: false # Resume from .done files + responses_create_params: + max_output_tokens: 32768 # Max generation length per response ``` +**`dependent_jobs`**: Each chunk spawns `dependent_jobs + 1` Slurm jobs chained via `afterany` dependency. When a job hits its time limit, the next job in the chain picks up from the last `.done` checkpoint. This avoids losing progress on long-running collections. + ### External vLLM Server (SDG Mode) Use a pre-launched vLLM server (any version) instead of the self-contained launch: @@ -300,7 +327,7 @@ stages: ```yaml stages: training: - num_nodes: 4 # Scale up for production + total_gpus: 32 # Scale up for production dependent_jobs: 3 # Multi-job chaining for long runs overrides: @@ -312,7 +339,11 @@ stages: train_global_batch_size: 1024 ``` -### Megatron Backend +### Training Backends + +The demo config (`qwen3_4b.yaml`) uses **FSDP v2** for the dense Qwen3-4B model. The production config (`qwen3_30b_a3b.yaml`) uses **Megatron** for the Qwen3-30B-A3B MoE model at 64 GPUs. + +**Production (Megatron):** ```yaml stages: @@ -337,24 +368,24 @@ stages: # Check Slurm jobs squeue --me -# View rollout collection logs -tail -f $OUTPUT_DIR/step-5-collect-rollouts/logs/ng_run_rs0_chunk0.log +# View rollout collection logs (replace {env_name} with your environment) +tail -f $MODEL_DIR/step-5-collect-rollouts/{env_name}/logs/ng_run_rs0_chunk0.log # View training logs -tail -f $OUTPUT_DIR/step-7-training/grpo-*/training-logs/*.log +tail -f $MODEL_DIR/step-8-training/grpo-*/training-logs/*.log ``` ### Troubleshooting **vLLM server fails to start:** - Check GPU availability: `sinfo -p interactive` -- Review vLLM logs: `cat $OUTPUT_DIR/step-5-collect-rollouts/logs/vllm_server_rs0_chunk0.log` +- Review vLLM logs: `cat $MODEL_DIR/step-5-collect-rollouts/{env_name}/logs/vllm_server_rs0_chunk0.log` - Ensure `tensor_parallel_size` doesn't exceed available GPUs **All rewards are 0.0 or 1.0:** -- Check the rollout analysis: `cat $OUTPUT_DIR/step-5-collect-rollouts/analysis_rs0/summary.txt` +- Check the rollout analysis: `cat $MODEL_DIR/step-5-collect-rollouts/{env_name}/rollout/analysis_rs0/summary.txt` - Policy-as-judge produces circular evaluation — use a separate judge for meaningful rewards -- Review judge logs: `cat $OUTPUT_DIR/step-5-collect-rollouts/logs/ng_run_rs0_chunk0.log` +- Review judge logs: `cat $MODEL_DIR/step-5-collect-rollouts/{env_name}/logs/ng_run_rs0_chunk0.log` **Rollouts missing metadata (uuid, question):** - The enrichment step automatically restores fields dropped by NeMo-Gym environments @@ -370,7 +401,17 @@ After GRPO training: ### Technical Details -This workflow uses **NeMo-RL** for GRPO training and **NeMo-Gym** for environment-based reward computation. The `equivalence_llm_judge` environment provides semantic equivalence scoring via an LLM judge. +This workflow uses **NeMo-RL** for GRPO training and **NeMo-Gym** for environment-based reward computation. + +**Supported NeMo-Gym environments:** + +| Environment | Reward Mode | Description | +|-------------|-------------|-------------| +| `equivalence_llm_judge` | Binary (0.0 / 1.0) | Semantic equivalence scoring via an LLM judge | +| `finance_sec_search` | Scaled (0.0 / 0.5 / 1.0) | Real SEC filing retrieval + LLM judge with partial credit | +| `mcqa` | Binary (0.0 / 1.0) | Multiple-choice QA with exact-match scoring (production only, not in demo) | + +The `finance_sec_search` environment requires prefetching SEC filings cache to a shared mounted path (see [INSTALL.md](../../../../INSTALL.md#prefetch-sec-filings-cache-for-finance_sec_search)). For comprehensive stage-by-stage documentation: - **[GRPO Stages Reference](../stages/grpo.md)** diff --git a/nvflow/cli/main.py b/nvflow/cli/main.py index 4ad4bfa..fbfe72e 100644 --- a/nvflow/cli/main.py +++ b/nvflow/cli/main.py @@ -40,22 +40,47 @@ # Run workflow nflow run-all --config nvflow/recipes/finance/workflows/training_sft.yaml -""" -import sys -from collections import defaultdict -from pathlib import Path -from typing import Annotated + # GRPO: run a single stage + nflow run collect_rollouts --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml + + # GRPO: run a single stage for one environment + nflow run collect_rollouts -c nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e equivalence_llm_judge + + # GRPO: run a stage for multiple environments + nflow run training -c nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e mcqa -e equivalence_llm_judge + + # GRPO: run all stages + nflow run-all --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml +""" -import typer -from omegaconf import OmegaConf -from rich import print -from rich.table import Table +import os # noqa: E402 + +# Limit BLAS/OpenMP thread pools to 1 on the login node. The nflow CLI only +# orchestrates Slurm jobs -- it never does BLAS compute. Without this cap, +# importing scipy/numpy spawns nproc threads (~96-188 on shared login nodes), +# causing a kernel futex storm that hangs the process for 2-30 minutes. +# Uses setdefault so users can override (e.g., OMP_NUM_THREADS=4 nflow ...). +# Slurm jobs are unaffected -- they run inside containers with their own env. +for _var in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS"): + os.environ.setdefault(_var, "1") + +import sys # noqa: E402 +from collections import defaultdict # noqa: E402 +from pathlib import Path # noqa: E402 +from typing import Annotated # noqa: E402 + +import typer # noqa: E402 +import yaml # noqa: E402 +from omegaconf import OmegaConf # noqa: E402 +from omegaconf.errors import OmegaConfBaseException # noqa: E402 +from rich import print # noqa: E402 +from rich.table import Table # noqa: E402 # Auto-discover all recipes and stages (must be after core imports) -import nvflow.recipes.finance # noqa: E402, F401 - import for side-effect -from nvflow import __version__ -from nvflow.core import BaseStage, StageRegistry, WorkflowRunner +import nvflow.recipes # noqa: E402, F401 - triggers recipe auto-discovery +from nvflow import __version__ # noqa: E402 +from nvflow.core import BaseStage, StageRegistry, WorkflowRunner # noqa: E402 app = typer.Typer( name="nflow", @@ -64,61 +89,73 @@ ) -def _get_stage_order(recipe_name: str, workflow_name: str) -> list[str] | None: - """Get pipeline_stages order from workflow config file. - - Args: - recipe_name: Recipe name (e.g., "finance") - workflow_name: Workflow name (e.g., "training_sft") +def _get_stage_order( + recipe_name: str, workflow_name: str +) -> tuple[list[str] | None, list[str] | None]: + """Get pipeline_stages order and stages config keys from workflow config. Returns: - List of stage names in pipeline order, or None if not found + (pipeline_order, stages_config_keys) -- either may be None. + *pipeline_order* is the active ``pipeline_stages`` list. + *stages_config_keys* is the key order from the ``stages:`` + section (preserves YAML insertion order), used as a secondary + hint for ordering optional stages not in pipeline_stages. """ - try: - # Look for workflow config file - workflow_dir = Path(__file__).parent.parent / "recipes" / recipe_name / "workflows" - if not workflow_dir.exists(): - return None - - # Try to find matching workflow config (search subdirectories too) - for config_file in workflow_dir.glob("**/*.yaml"): - try: - cfg = OmegaConf.load(config_file) - cfg_workflow_name = cfg.get("workflow", {}).get("name") - if cfg_workflow_name == workflow_name: - return cfg.get("pipeline_stages", []) - except Exception: - continue - return None - except Exception: - return None + workflow_dir = Path(__file__).parent.parent / "recipes" / recipe_name / "workflows" + if not workflow_dir.exists(): + return None, None - -def _order_stages(stages: list[str], pipeline_order: list[str] | None) -> list[str]: - """Order stages based on pipeline config, fallback to alphabetical. - - Args: - stages: List of stage names to order - pipeline_order: Desired order from config, or None - - Returns: - Ordered list of stage names + parse_failures: list[tuple[Path, BaseException]] = [] + for config_file in workflow_dir.glob("**/*.yaml"): + try: + cfg = OmegaConf.load(config_file) + except (OmegaConfBaseException, yaml.YAMLError, OSError) as exc: + parse_failures.append((config_file, exc)) + continue + if cfg.get("workflow", {}).get("name") == workflow_name: + pipeline = cfg.get("pipeline_stages", []) + stages_keys = list(cfg.get("stages", {}).keys()) + return pipeline, stages_keys or None + + if parse_failures: + print( + f"[nvflow] WARNING: failed to parse {len(parse_failures)} workflow " + f"YAML(s) under {workflow_dir}; stage order falling back to " + "alphabetical:", + file=sys.stderr, + ) + for path, err in parse_failures: + print( + f" - {path}: {type(err).__name__}: {err}", + file=sys.stderr, + ) + return None, None + + +def _order_stages( + stages: list[str], + pipeline_order: list[str] | None, + stages_config_keys: list[str] | None = None, +) -> list[str]: + """Order stages based on config, fallback to alphabetical. + + Uses *stages_config_keys* (the ``stages:`` section key order) when + available -- this includes optional stages like ``compute_rewards`` + in their logical position even when they are commented out of + ``pipeline_stages``. Falls back to *pipeline_order*, then + alphabetical. """ - if not pipeline_order: + order_source = stages_config_keys or pipeline_order + if not order_source: return sorted(stages) - # Maintain pipeline order, append remaining stages alphabetically ordered = [] remaining = set(stages) - - for stage in pipeline_order: + for stage in order_source: if stage in remaining: ordered.append(stage) remaining.remove(stage) - - # Add any stages not in config (e.g., newly registered) ordered.extend(sorted(remaining)) - return ordered @@ -222,8 +259,8 @@ def list_stages( print(f" [cyan]{workflow_name}:[/cyan]") stages = by_recipe[recipe_name][workflow_name] - pipeline_order = _get_stage_order(recipe_name, workflow_name) - ordered_stages = _order_stages(stages, pipeline_order) + pipeline_order, stages_config_keys = _get_stage_order(recipe_name, workflow_name) + ordered_stages = _order_stages(stages, pipeline_order, stages_config_keys) for stage_name in ordered_stages: print(f" • {stage_name}") @@ -244,12 +281,16 @@ def run( config: Annotated[ str, typer.Option("--config", "-c", help="Path to workflow configuration file") ], + environment: Annotated[ + list[str] | None, + typer.Option("--environment", "-e", help="Run for specific environment(s) only"), + ] = None, ): """Run one or more specific stages.""" try: runner = WorkflowRunner(config) - runner.run(stages=stages) + runner.run(stages=stages, environment=environment) except Exception as e: print(f"[red]Error:[/red] {e}") sys.exit(1) @@ -258,12 +299,16 @@ def run( @app.command(name="run-all") def run_all( config: str = typer.Option(..., "--config", "-c", help="Path to workflow configuration file"), + environment: Annotated[ + list[str] | None, + typer.Option("--environment", "-e", help="Run for specific environment(s) only"), + ] = None, ): """Run all stages defined in the workflow config.""" try: runner = WorkflowRunner(config) - runner.run() # No stages argument = run all + runner.run(environment=environment) except Exception as e: print(f"[red]Error:[/red] {e}") sys.exit(1) diff --git a/nvflow/core/base_stage.py b/nvflow/core/base_stage.py index 9e5d5f4..c19ae56 100644 --- a/nvflow/core/base_stage.py +++ b/nvflow/core/base_stage.py @@ -62,7 +62,7 @@ def execute( >>> config = { ... "input_dir": "/data/raw", ... "output_dir": "/data/processed", - ... "stage_kwargs": {"partition": "cpu"} + ... "stage_kwargs": {"installation_command": "pip install -q pandas"} ... } >>> stage.execute(config, "nrt", "my-exp-data-download", run_after=None) """ diff --git a/nvflow/core/discovery.py b/nvflow/core/discovery.py new file mode 100644 index 0000000..c9409a1 --- /dev/null +++ b/nvflow/core/discovery.py @@ -0,0 +1,100 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Tolerant-but-loud helpers for stage and recipe discovery. + +Replaces the unsafe ``except ImportError: pass`` pattern that silently +dropped entire subpackages from the stage registry. Failures still +don't abort discovery (siblings keep registering), but each failure is +printed to stderr so operators see what broke and why. + +Set ``NVFLOW_DEBUG_IMPORTS=1`` for full tracebacks. +""" + +import importlib +import os +import sys +import traceback +from pathlib import Path + + +def import_stage_modules(package: str, current_dir: Path) -> None: + """Import every ``.py`` file in *current_dir* as a submodule of *package*. + + Files starting with ``_`` or ``.`` are skipped. Import failures are + collected and reported on stderr; sibling modules still load. + """ + failures: list[tuple[str, BaseException]] = [] + for file in sorted(current_dir.glob("*.py")): + if file.name.startswith(".") or file.stem.startswith("_"): + continue + module_path = f"{package}.{file.stem}" + try: + importlib.import_module(f".{file.stem}", package=package) + except Exception as exc: + failures.append((module_path, exc)) + if failures: + _report_failures(failures, kind="module") + + +def import_stage_subpackages(package: str, current_dir: Path) -> None: + """Import every immediate subdirectory of *current_dir* that has an ``__init__.py``. + + Directories starting with ``_`` or ``.`` are skipped. Import + failures are collected and reported on stderr; sibling packages + still load. + """ + failures: list[tuple[str, BaseException]] = [] + for subdir in sorted(current_dir.iterdir()): + if not subdir.is_dir() or subdir.name.startswith(("_", ".")): + continue + if not (subdir / "__init__.py").exists(): + continue + module_path = f"{package}.{subdir.name}" + try: + importlib.import_module(f".{subdir.name}", package=package) + except Exception as exc: + failures.append((module_path, exc)) + if failures: + _report_failures(failures, kind="sub-package") + + +def _is_duplicate_registration(exc: BaseException) -> bool: + """Detect StageRegistry's 'already registered' ValueError.""" + return isinstance(exc, ValueError) and "already registered" in str(exc) + + +def _report_failures(failures: list[tuple[str, BaseException]], kind: str) -> None: + """Print a concise warning for each failed import to stderr. + + Duplicate-registration ValueErrors are surfaced with a CRITICAL + prefix so the contract violation is obvious in scrollback. + """ + debug = bool(os.environ.get("NVFLOW_DEBUG_IMPORTS")) + has_critical = any(_is_duplicate_registration(exc) for _, exc in failures) + print( + f"\n[nvflow] {'CRITICAL' if has_critical else 'WARNING'}: failed to " + f"import {len(failures)} stage {kind}(s); their stages will NOT be " + "registered:", + file=sys.stderr, + ) + for module_path, exc in failures: + prefix = "[nvflow] CRITICAL: " if _is_duplicate_registration(exc) else " - " + print( + f"{prefix}{module_path}: {type(exc).__name__}: {exc}", + file=sys.stderr, + ) + if debug: + traceback.print_exception(exc, file=sys.stderr) + print(file=sys.stderr) diff --git a/nvflow/core/stage_registry.py b/nvflow/core/stage_registry.py index c731ea4..49daae9 100644 --- a/nvflow/core/stage_registry.py +++ b/nvflow/core/stage_registry.py @@ -27,6 +27,7 @@ - prepare_data """ +import sys from pathlib import Path from typing import Any @@ -115,13 +116,19 @@ def _get_recipe_config(cls, recipe: str) -> dict[str, Any] | None: recipe_dir = Path(__file__).parent.parent / "recipes" / recipe for filename in ("recipe.yaml", "recipe.yml"): config_path = recipe_dir / filename - if config_path.exists(): - try: - with open(config_path) as f: - cls._recipe_configs[recipe] = yaml.safe_load(f) - return cls._recipe_configs[recipe] - except (OSError, yaml.YAMLError): - pass # Fall back to default behavior + if not config_path.exists(): + continue + try: + with open(config_path) as f: + cls._recipe_configs[recipe] = yaml.safe_load(f) + return cls._recipe_configs[recipe] + except (OSError, yaml.YAMLError) as exc: + print( + f"[nvflow] WARNING: failed to load {config_path}: " + f"{type(exc).__name__}: {exc} -- workflow ordering for " + f"recipe '{recipe}' will fall back to alphabetical.", + file=sys.stderr, + ) cls._recipe_configs[recipe] = None return None diff --git a/nvflow/core/workflow_runner.py b/nvflow/core/workflow_runner.py index 6ee8076..d88a547 100644 --- a/nvflow/core/workflow_runner.py +++ b/nvflow/core/workflow_runner.py @@ -14,6 +14,7 @@ # """Workflow runner for executing stage sequences with dependency management.""" +import sys from pathlib import Path from omegaconf import OmegaConf @@ -218,17 +219,24 @@ def _expand_checkpoint_pipeline_stages(self, checkpoints: dict) -> None: expanded.append(stage) self.config["pipeline_stages"] = expanded - def run(self, stages: list[str] | None = None) -> None: + def run( + self, + stages: list[str] | None = None, + environment: list[str] | None = None, + ) -> None: """Run workflow stages. Args: stages: List of stage names to run. If None, runs all stages defined in config's pipeline_stages. + environment: Optional list of environment names to run for. + If None, runs all environments defined in config. Example: - >>> runner.run() # Run all stages + >>> runner.run() # Run all stages, all environments >>> runner.run(stages=["download"]) # Run one stage - >>> runner.run(stages=["download", "validate"]) # Run multiple + >>> runner.run(environment=["equivalence_llm_judge"]) # Single env + >>> runner.run(environment=["mcqa", "equivalence_llm_judge"]) """ all_stages = self.config["pipeline_stages"] stages_to_run = stages if stages else all_stages @@ -236,31 +244,81 @@ def run(self, stages: list[str] | None = None) -> None: # Validate that requested stages exist in config self._validate_stages(stages_to_run, all_stages) + # Warn about sibling stages that are declared in pipeline_stages + # but not currently registered (e.g., their import failed). + self._preflight_pipeline_health(all_stages, stages_to_run) + + if environment: + environments = self.config.get("environments", {}) + for env_name in environment: + if env_name not in environments: + available = ", ".join(environments.keys()) + raise ValueError(f"Unknown environment '{env_name}'. Available: {available}") + header(f"NVFlow - Running Workflow: {self.workflow_name}") detail("Workflow Type", self.workflow_type) detail("Cluster", self.cluster) detail("Stages to run", f"{len(stages_to_run)}/{len(all_stages)}") + if environment: + detail("Environment", ", ".join(environment)) # Execute stages completed_stages = [] for stage_name in stages_to_run: - self._run_stage(stage_name) + self._run_stage(stage_name, environment=environment, stages_to_run=stages_to_run) completed_stages.append(stage_name) header("✅ Workflow Complete!") success(f"Completed {len(completed_stages)} stage(s): {', '.join(completed_stages)}") - def _run_stage(self, stage_name: str) -> None: + def _preflight_pipeline_health( + self, + all_stages: list[str], + stages_to_run: list[str], + ) -> None: + """Warn when pipeline_stages contains unregistered stages. + + Subset runs only validate the requested stages, so siblings that + failed to import sit unnoticed until the user runs the full + pipeline. This method surfaces them upfront on stderr (warn-only, + does not block the run). + """ + deferred = [s for s in all_stages if s not in stages_to_run] + missing = [s for s in deferred if not StageRegistry.has(self.recipe, self.workflow_name, s)] + if missing: + print( + "[nvflow] WARNING: pipeline declares stages that are not " + "currently registered (other stages may have failed to " + "import; check earlier discovery warnings). The workflow " + "WILL NOT complete end-to-end without fixing these:", + file=sys.stderr, + ) + for s in missing: + print(f" - {self.recipe}.{self.workflow_name}.{s}", file=sys.stderr) + + def _run_stage( + self, + stage_name: str, + environment: list[str] | None = None, + stages_to_run: list[str] | None = None, + ) -> None: """Run a single stage. Args: stage_name: Short stage name (e.g., "sft", "generate_qa", "download") Stage is resolved using recipe and workflow context + environment: Optional list of environment names to filter to. + stages_to_run: Stages being submitted in this session. Slurm + deps are only wired for stages in this list; cross-session + deps are dropped because nemo-run's job directory may not + contain their records. """ section(f"Running Stage: {stage_name}") - # Get stage configuration - stage_config = self.config["stages"][stage_name] + # Get stage configuration and inject environment filter + stage_config = {**self.config["stages"][stage_name]} + if environment is not None: + stage_config["_environment"] = environment # Get stage class from hierarchical registry with explicit context if not StageRegistry.has(self.recipe, self.workflow_name, stage_name): @@ -278,13 +336,11 @@ def _run_stage(self, stage_name: str) -> None: # Generate experiment name for this stage expname = self._get_expname(stage_name, stage_config) - # Get dependencies (other stages this stage depends on) + # Only wire Slurm deps for stages submitted in this session. dependencies = stage_config.get("dependencies", []) - run_after = ( - [self._get_expname(dep, self.config["stages"][dep]) for dep in dependencies] - if dependencies - else None - ) + if stages_to_run is not None: + dependencies = [d for d in dependencies if d in stages_to_run] + run_after = self._get_run_after_names(dependencies, environment) if dependencies: info(f"Dependencies: {', '.join(dependencies)}") @@ -324,6 +380,52 @@ def _get_expname(self, stage_name: str, stage_config: dict) -> str: return base_name + def _get_run_after_names( + self, + dependencies: list[str], + environment: list[str] | None, + ) -> list[str] | None: + """Build ``run_after`` experiment names for Slurm dependency tracking. + + Per-environment stages submit jobs with ``{expname}-{env_name}`` + suffixes. This method expands dependency names to match those + suffixed experiment names so that ``nemo-run`` can resolve the + correct Slurm job handles. + + For stages without ``environments``, the base experiment name is + used (unchanged from previous behaviour). + """ + if not dependencies: + return None + names: list[str] = [] + for dep in dependencies: + dep_config = self.config["stages"][dep] + base = self._get_expname(dep, dep_config) + if dep_config.get("environments"): + env_names = self._resolve_env_names(dep_config, environment) + names.extend(f"{base}-{env}" for env in env_names) + else: + names.append(base) + return names or None + + @staticmethod + def _resolve_env_names( + stage_config: dict, + environment: list[str] | None, + ) -> list[str]: + """Return the environment names a stage will iterate over. + + Mirrors the filtering logic of ``resolve_environments()`` in + ``nvflow.lib.rl.helpers`` but operates on the raw config dict + so the core module stays independent of recipe-specific code. + """ + envs = stage_config.get("environments", {}) + if not envs: + return [] + if environment: + return [e for e in environment if e in envs] + return list(envs.keys()) + def _validate_stages(self, stages_to_run: list[str], all_stages: list[str]) -> None: """Validate that requested stages exist and are registered. @@ -357,6 +459,31 @@ def _validate_stages(self, stages_to_run: list[str], all_stages: list[str]) -> N f"found in stages section" ) + # Walk the transitive dependency graph of stages_to_run and verify + # each dependency is both configured and registered. Without this, + # the runner builds Slurm --dependency names for stages that were + # never submitted (their import failed silently). + closure: set[str] = set(stages_to_run) + queue: list[str] = list(stages_to_run) + while queue: + s = queue.pop() + for d in self.config["stages"].get(s, {}).get("dependencies", []): + if d in closure: + continue + closure.add(d) + queue.append(d) + if d not in self.config["stages"]: + raise ValueError( + f"Stage '{s}' depends on '{d}' which has no config block in 'stages:'." + ) + if not StageRegistry.has(self.recipe, self.workflow_name, d): + raise ValueError( + f"Stage '{s}' depends on '{d}' which is not registered " + f"at {self.recipe}.{self.workflow_name}.{d}. Check " + "earlier discovery warnings on stderr for the " + "underlying import failure." + ) + def validate_config(self) -> None: """Validate the workflow configuration. diff --git a/nvflow/lib/gpu_layout.py b/nvflow/lib/gpu_layout.py new file mode 100644 index 0000000..4afe6ea --- /dev/null +++ b/nvflow/lib/gpu_layout.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Compute Slurm node layout from total GPU count and cluster hardware. + +Workflow YAMLs express intent as ``total_gpus`` (how many GPUs the model +needs). Cluster configs declare hardware as ``gpus_per_node``. This module +bridges the two so that the same workflow YAML works on clusters with +different GPU-per-node counts. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +LOG = logging.getLogger(__name__) + +_DEFAULT_GPUS_PER_NODE = 8 + + +@dataclass(frozen=True) +class GpuLayout: + """Resolved Slurm node layout for a training job.""" + + num_nodes: int + gpus_per_node: int + + @property + def total_gpus(self) -> int: + return self.num_nodes * self.gpus_per_node + + +def resolve_gpu_layout( + config: dict, + cluster_config: dict | None = None, +) -> GpuLayout: + """Compute ``(num_nodes, gpus_per_node)`` from workflow + cluster config. + + Resolution order: + + 1. **``total_gpus``** (preferred) -- portable across clusters. + ``num_nodes = total_gpus // gpus_per_node``. + 2. **``num_nodes`` + optional ``num_gpus``** -- legacy, cluster-specific. + Passed through directly. + 3. **Neither** -- defaults to a single node. + + Args: + config: Stage/workflow config dict (may contain ``total_gpus``, + ``num_nodes``, ``num_gpus``). + cluster_config: Cluster config dict (may contain ``gpus_per_node``). + ``None`` is tolerated for local/non-Slurm execution. + + Returns: + Resolved :class:`GpuLayout`. + + Raises: + ValueError: If ``total_gpus`` is not evenly divisible by + ``gpus_per_node``. + """ + gpus_per_node = _DEFAULT_GPUS_PER_NODE + if cluster_config: + gpus_per_node = cluster_config.get("gpus_per_node", _DEFAULT_GPUS_PER_NODE) + + if "total_gpus" in config: + total = config["total_gpus"] + if total % gpus_per_node != 0: + raise ValueError( + f"total_gpus ({total}) is not evenly divisible by " + f"gpus_per_node ({gpus_per_node}). Adjust total_gpus in the " + f"workflow YAML or gpus_per_node in the cluster config." + ) + num_nodes = total // gpus_per_node + return GpuLayout(num_nodes=num_nodes, gpus_per_node=gpus_per_node) + + if "num_nodes" in config: + num_nodes = config["num_nodes"] + num_gpus = config.get("num_gpus", gpus_per_node) + LOG.debug( + "Using legacy num_nodes=%d / num_gpus=%d (not portable across clusters)", + num_nodes, + num_gpus, + ) + return GpuLayout(num_nodes=num_nodes, gpus_per_node=num_gpus) + + return GpuLayout(num_nodes=1, gpus_per_node=gpus_per_node) diff --git a/nvflow/lib/rl/__init__.py b/nvflow/lib/rl/__init__.py index 0284316..721c349 100644 --- a/nvflow/lib/rl/__init__.py +++ b/nvflow/lib/rl/__init__.py @@ -15,14 +15,16 @@ """RL infrastructure library -- rollout collection and reward computation. Submodules: - rollout -- Slurm pipeline for collecting rollouts - verify -- Slurm pipeline for re-judging rollouts - helpers -- shared utilities (vLLM config, judge config, shell templates) - resume_filter -- standalone worker: fine-grained resume filtering - verify_worker -- standalone worker: re-judges rollouts via NeMo-Gym ServerClient + rollout -- Slurm pipeline for collecting rollouts + verify -- Slurm pipeline for re-judging rollouts + helpers -- shared utilities (vLLM config, judge config, shell templates) + create_overlay -- standalone worker: creates symlinked model overlay dirs + resume_filter -- standalone worker: fine-grained resume filtering + verify_worker -- standalone worker: re-judges rollouts via NeMo-Gym ServerClient -Worker scripts (resume_filter, verify_worker) run inside the Slurm -container's Gym venv. This __init__.py is intentionally kept -import-free so that ``python -m nvflow.lib.rl.`` does not -trigger the nemo_skills dependency chain. +Worker scripts (create_overlay, resume_filter, verify_worker) run +inside the Slurm container with ``PYTHONPATH=/workspace``. This +__init__.py is intentionally kept import-free so that +``python -m nvflow.lib.rl.`` does not trigger the nemo_skills +dependency chain. """ diff --git a/nvflow/lib/rl/create_overlay.py b/nvflow/lib/rl/create_overlay.py new file mode 100644 index 0000000..430752a --- /dev/null +++ b/nvflow/lib/rl/create_overlay.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Create a symlinked model overlay directory with a patched ``config.json``. + +All files from the original model directory are symlinked into the +overlay; only ``config.json`` is replaced with a real file that merges +the original config with the supplied overrides. + +The overlay name is expected to be content-addressed (caller provides +it), and a marker file (``.overlay_overrides.json``) tracks the current +overrides so the overlay is only recreated when they change. + +Standalone script that runs inside the Slurm container with +``PYTHONPATH=/workspace``. + +Usage:: + + python3 -m nvflow.lib.rl.create_overlay \\ + --model-path /hf_models/Qwen/Qwen3-4B \\ + --overlay-path /hf_models/Qwen/Qwen3-4B-overlay-a1b2c3d4e5f6 \\ + --overrides '{"rope_scaling": {"rope_type": "yarn", "factor": 3.2, "original_max_position_embeddings": 40960}}' +""" + +import argparse +import json +import os +import sys + +from nvflow.utils import setup_logger + +logger = setup_logger(__name__) + + +def create_overlay(model_path: str, overlay_path: str, overrides: dict) -> None: + """Create or verify a symlinked model overlay directory.""" + marker = os.path.join(overlay_path, ".overlay_overrides.json") + expected = json.dumps(overrides, sort_keys=True) + + if os.path.exists(marker): + with open(marker) as f: + if f.read() == expected: + logger.info("Model overlay up-to-date: %s", overlay_path) + return + + os.makedirs(overlay_path, exist_ok=True) + + for name in os.listdir(model_path): + link = os.path.join(overlay_path, name) + if os.path.exists(link) or os.path.islink(link): + os.unlink(link) + target = os.path.join(model_path, name) + os.symlink(os.path.relpath(target, overlay_path), link) + + cfg_path = os.path.join(overlay_path, "config.json") + if os.path.islink(cfg_path): + os.unlink(cfg_path) + + with open(os.path.join(model_path, "config.json")) as f: + cfg = json.load(f) + cfg.update(overrides) + with open(cfg_path, "w") as f: + json.dump(cfg, f, indent=2) + + with open(marker, "w") as f: + f.write(expected) + + logger.info("Created model overlay: %s", overlay_path) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Create a symlinked model overlay with patched config.json", + ) + parser.add_argument("--model-path", required=True, help="Path to the original model directory") + parser.add_argument("--overlay-path", required=True, help="Path for the overlay directory") + parser.add_argument("--overrides", required=True, help="JSON string of HF config overrides") + args = parser.parse_args() + + try: + overrides = json.loads(args.overrides) + except json.JSONDecodeError as e: + logger.error("Invalid JSON in --overrides: %s", e) + sys.exit(1) + + create_overlay(args.model_path, args.overlay_path, overrides) diff --git a/nvflow/lib/rl/helpers.py b/nvflow/lib/rl/helpers.py index 1a294b0..fdda639 100644 --- a/nvflow/lib/rl/helpers.py +++ b/nvflow/lib/rl/helpers.py @@ -19,12 +19,17 @@ General utilities: - ``resolve_host_path``: maps /workspace/ container paths to host paths. + - ``check_launcher_cwd``: preflight check -- fails fast if cwd is not the + nvflow project root (required by ``resolve_host_path``). - ``build_config_paths_str``: assembles NeMo-Gym config_paths with overlay. vLLM server configuration: - ``build_vllm_server_args``: converts vLLM YAML config to ``--key value`` CLI args suitable for ``nemo_skills.pipeline.utils.scripts.ServerScript(server_args=...)``. - ``compute_num_gpus``: auto-compute Slurm GPU request from per-endpoint num_gpus. + - ``_overlay_path`` / ``_build_overlay_setup_cmd``: content-addressed model + overlay directories for per-environment HF config overrides (e.g. YaRN). + The overlay is created inside the Slurm job via :mod:`nvflow.lib.rl.create_overlay`. Judge configuration: - ``determine_judge_mode``, ``validate_judge_config``: mode detection & validation. @@ -36,8 +41,12 @@ Shell / script templates: - ``SHELL_WAIT_FOR_SERVER``: reusable bash function for health-check polling. + - ``CONTAINER_WORKSPACE``: mount point for the nvflow project root inside + Slurm containers (used for ``PYTHONPATH`` in inline bash scripts). """ +import hashlib +import json from pathlib import Path from typing import Any @@ -46,29 +55,118 @@ # ============================================================================ -def resolve_host_path(container_path: str) -> Path: - """Map a /workspace/ container path to the host filesystem. +CONTAINER_WORKSPACE = "/workspace" +"""Mount point for the nvflow project root inside Slurm containers.""" + +CONTAINER_CODE_DIR = "/nemo_run/code" +"""Snapshot of the nvflow codebase inside Slurm containers (nemo-run packager).""" + +_WORKSPACE_PREFIX = CONTAINER_WORKSPACE + "/" - The Lustre mount maps the nvflow project root to /workspace inside the - container (see cluster_configs/my_cluster.yaml mounts). On the submission host - /workspace doesn't exist, so we replace the prefix with "./" which - resolves to the same Lustre directory. - IMPORTANT: Assumes ``uv run nflow ...`` is executed from the nvflow - project root. ``uv run`` enforces this by default. +def resolve_host_path(container_path: str) -> Path: + """Map a ``/workspace/`` container path to the host filesystem. + + The Slurm container mounts the nvflow project root at + :data:`CONTAINER_WORKSPACE` (``/workspace``). On the submission host + that path doesn't exist, so the prefix is replaced with ``./`` which + resolves to the same Lustre directory -- provided the launcher is run + from the nvflow project root. + + **Launcher requirement**: the process calling this function must be + running on a host that has Lustre mounted and the cwd must be the + nvflow project root (``uv run nflow ...`` enforces this by default). + Call :func:`check_launcher_cwd` early to fail fast with a clear + message if this assumption is violated. """ - if container_path.startswith("/workspace/") and not Path("/workspace").exists(): - return Path(container_path.replace("/workspace/", "./")) + if container_path.startswith(_WORKSPACE_PREFIX) and not Path(CONTAINER_WORKSPACE).exists(): + return Path(container_path.replace(_WORKSPACE_PREFIX, "./")) return Path(container_path) +def check_launcher_cwd() -> None: + """Validate that the launcher is running from the nvflow project root. + + :func:`resolve_host_path` maps container paths to ``./`` relative + paths, which only works when the cwd is the nvflow project root on a + Lustre-mounted host. This function checks for the ``pyproject.toml`` + marker file and raises early with a clear message if it's missing. + """ + marker = Path("pyproject.toml") + if not marker.exists(): + raise RuntimeError( + f"Launcher must run from the nvflow project root " + f"(expected '{marker}' in cwd={Path.cwd()}). " + f"Use 'uv run nflow ...' which enforces this automatically." + ) + + +VLLM_MODEL = "responses_api_models/vllm_model/configs/vllm_model.yaml" +VLLM_MODEL_FOR_TRAINING = "responses_api_models/vllm_model/configs/vllm_model_for_training.yaml" +SERVER_CONTAINER = "vllm" +"""Container name for vLLM server jobs (matches cluster_configs key).""" + + +def resolve_environments(config: dict[str, Any]) -> dict[str, Any]: + """Return the environments to process based on the ``_environment`` filter. + + ``config["_environment"]`` may be a single name (``str``), a list of + names (``list[str]``), or ``None``. When set, only the matching + entries are returned (preserving order). When ``None``, all + environments from ``config["environments"]`` are returned. + + Raises ``ValueError`` if a requested environment doesn't exist. + """ + envs = config.get("environments", {}) + if not envs: + raise ValueError("'environments' dict is required but missing from config") + selected = config.get("_environment") + if selected: + names = [selected] if isinstance(selected, str) else list(selected) + for name in names: + if name not in envs: + available = ", ".join(envs.keys()) + raise ValueError(f"Unknown environment '{name}'. Available: {available}") + return {name: envs[name] for name in names} + return envs + + +def get_env_from_environments(config: dict[str, Any]) -> tuple[str, str, str]: + """Derive NeMo-Gym identifiers from the ``environments`` dict. + + Returns ``(resources_server_name, env_inner_name, agent_name)`` where: + + - *resources_server_name*: NeMo-Gym top-level config key. Defaults to + the env dict key but can be overridden via ``resources_server_name`` + in the environment config (needed when the NeMo-Gym YAML uses a + different top-level key, e.g. ``finance_sec_search_resources_server``). + - *env_inner_name*: the env dict key, which always matches the inner + ``resources_servers`` key in NeMo-Gym configs. + - *agent_name*: for single-env returns the configured agent name; for + multi-env returns empty (triggers ``agent_ref`` routing). + """ + environments = config["environments"] + env_names = list(environments.keys()) + if len(env_names) == 1: + env_cfg = environments[env_names[0]] + rs_name = env_cfg.get("resources_server_name", env_names[0]) + return rs_name, env_names[0], env_cfg.get("agent_name", f"{env_names[0]}_simple_agent") + env_cfg = environments[env_names[0]] + rs_name = env_cfg.get("resources_server_name", env_names[0]) + return rs_name, env_names[0], "" + + def build_config_paths_str(config: dict[str, Any]) -> str: """Build the comma-separated NeMo-Gym config_paths string. - Starts from ``nemo_gym_config_paths`` and appends the agent config - overlay from ``prepare_data_dir`` if set. + Prepends ``vllm_model.yaml`` and combines all environment + config_paths from the ``environments`` dict. Appends the agent + config overlay from ``prepare_data_dir`` if set. """ - config_paths = list(config["nemo_gym_config_paths"]) + environments = config["environments"] + config_paths = [VLLM_MODEL] + for env_cfg in environments.values(): + config_paths.extend(env_cfg.get("config_paths", [])) prepare_data_dir = config.get("prepare_data_dir") if prepare_data_dir: config_paths.append(f"{prepare_data_dir}/agent_config_overlay.yaml") @@ -76,10 +174,10 @@ def build_config_paths_str(config: dict[str, Any]) -> str: # ============================================================================ -# Judge configuration +# vLLM & judge configuration # ============================================================================ -JUDGE_NON_VLLM_KEYS = frozenset( +NON_VLLM_KEYS = frozenset( { "num_gpus", "server_nodes", @@ -91,9 +189,16 @@ def build_config_paths_str(config: dict[str, Any]) -> str: "uses_reasoning_parser", "tensor_parallel_size", "trust_remote_code", + "hf_config_overrides", + "server_entrypoint", } ) -"""Keys in ``judge_vllm`` config that are NOT vLLM server CLI flags.""" +"""Keys in vLLM config dicts that are NOT ``vllm serve`` CLI flags. + +Used by both policy and judge vLLM configs -- callers strip these before +passing the remaining keys to :func:`build_vllm_server_args`. +``hf_config_overrides`` is handled separately via model overlay (see :func:`_overlay_path`). +""" def _judge_cfg(config: dict[str, Any]) -> dict[str, Any]: @@ -191,11 +296,48 @@ def build_vllm_server_args(overrides: dict[str, Any]) -> str: if isinstance(value, bool): if value: parts.append(cli_key) + elif isinstance(value, str) and value.startswith("{"): + parts.append(f"{cli_key} '{value}'") else: parts.append(f"{cli_key} {value}") return " ".join(parts) +def _overlay_path(model_path: str, hf_config_overrides: dict[str, Any]) -> str: + """Compute the deterministic overlay directory path for *model_path*. + + The overlay name is content-addressed: it includes a hash of the + serialized *hf_config_overrides* so a new overlay is created only + when the overrides change. + """ + override_json = json.dumps(hf_config_overrides, sort_keys=True) + digest = hashlib.sha256(override_json.encode()).hexdigest()[:12] + model_name = model_path.rstrip("/").rsplit("/", 1)[-1] + overlay_name = f"{model_name}-overlay-{digest}" + return model_path.rstrip("/").rsplit("/", 1)[0] + "/" + overlay_name + + +def _build_overlay_setup_cmd( + model_path: str, + overlay_path: str, + hf_config_overrides: dict[str, Any], +) -> str: + """Return a bash snippet that creates a symlinked model overlay. + + The snippet is designed to run **inside the Slurm job** (where + container mounts are available) before the vLLM server starts. + It invokes :mod:`nvflow.lib.rl.create_overlay` which is mounted + at ``/nemo_run/code/`` inside the container. + """ + overrides_json = json.dumps(hf_config_overrides, sort_keys=True) + return ( + f"PYTHONPATH={CONTAINER_CODE_DIR} python3 -m nvflow.lib.rl.create_overlay" + f" --model-path {model_path}" + f" --overlay-path {overlay_path}" + f" --overrides '{overrides_json}'" + ) + + def build_judge_ng_run_overrides( config: dict[str, Any], judge_mode: str, @@ -204,7 +346,9 @@ def build_judge_ng_run_overrides( ) -> str: """Build ng_run CLI overrides that configure the judge model server. - Reads from ``config["judge_vllm"]`` and ``config["environment_name"]``. + Reads from ``config["judge_vllm"]``, ``config["environment_name"]`` + (NeMo-Gym top-level key), and ``config["environment_inner_name"]`` + (inner ``resources_servers`` key, defaults to ``environment_name``). For policy_as_judge: returns empty string (judge uses policy_model). Args: @@ -212,8 +356,9 @@ def build_judge_ng_run_overrides( """ jcfg = _judge_cfg(config) env_name = config["environment_name"] + inner_name = config.get("environment_inner_name", env_name) judge_server_override = ( - f' "+{env_name}.resources_servers.{env_name}.judge_model_server.name=judge_model" \\\n' + f' "+{env_name}.resources_servers.{inner_name}.judge_model_server.name=judge_model" \\\n' ) if judge_mode in ("local_vllm", "external_vllm"): @@ -244,6 +389,7 @@ def build_judge_ng_run_overrides( ] return "".join(lines) + # policy_as_judge: no judge overrides needed return "" @@ -251,7 +397,8 @@ def build_judge_nemo_gym_config( config: dict[str, Any], judge_mode: str, *, - environment_name: str = "equivalence_llm_judge", + environment_name: str, + environment_inner_name: str = "", judge_url_var: str = "", ) -> dict[str, Any]: """Build a dict fragment to merge into NeMo-Gym ``initial_global_config_dict``. @@ -273,7 +420,12 @@ def build_judge_nemo_gym_config( Args: config: Stage config containing ``judge_vllm`` sub-config. judge_mode: One of the modes returned by :func:`determine_judge_mode`. - environment_name: NeMo-Gym resource server name (top-level config key). + environment_name: NeMo-Gym top-level config key (may differ from + the inner ``resources_servers`` key for environments that use + the ``_resources_server`` suffix convention). + environment_inner_name: Inner ``resources_servers`` key. Defaults + to *environment_name* when empty (backward compatible with + environments where both keys are the same). judge_url_var: For ``local_vllm`` mode, the URL (or shell variable) where the judge vLLM engine will be reachable. Ignored for other modes. @@ -282,11 +434,12 @@ def build_judge_nemo_gym_config( return {} jcfg = _judge_cfg(config) + inner_name = environment_inner_name or environment_name judge_server_name_override = { environment_name: { "resources_servers": { - environment_name: { + inner_name: { "judge_model_server": {"name": "judge_model"}, }, }, @@ -313,22 +466,20 @@ def build_judge_nemo_gym_config( **judge_server_name_override, } - if judge_mode == "openai": - return { - "judge_model": { - "responses_api_models": { - "openai_model": { - "entrypoint": "app.py", - "openai_base_url": jcfg["openai_base_url"], - "openai_api_key": jcfg.get("openai_api_key", ""), - "openai_model": jcfg["openai_model"], - }, + # judge_mode == "openai" + return { + "judge_model": { + "responses_api_models": { + "openai_model": { + "entrypoint": "app.py", + "openai_base_url": jcfg["openai_base_url"], + "openai_api_key": jcfg.get("openai_api_key", ""), + "openai_model": jcfg["openai_model"], }, }, - **judge_server_name_override, - } - - return {} + }, + **judge_server_name_override, + } # ============================================================================ @@ -344,6 +495,22 @@ def build_judge_nemo_gym_config( } """ +SHELL_READ_PORT_FILE = """\ +read_port_file() { + local path="$1" name="$2" timeout="${3:-120}" + local elapsed=0 + while [ ! -f "$path" ]; do + sleep 1 + elapsed=$((elapsed + 1)) + if [ $elapsed -ge $timeout ]; then + echo "ERROR: $name port file not found after ${timeout}s: $path" >&2 + exit 1 + fi + done + cat "$path" +} +""" + SHELL_WAIT_FOR_SERVER = """\ wait_for_server() { local url="$1" name="$2" pid="$3" max_attempts="$4" log="$5" diff --git a/nvflow/lib/rl/resume_filter.py b/nvflow/lib/rl/resume_filter.py index 44425f1..6cbc449 100644 --- a/nvflow/lib/rl/resume_filter.py +++ b/nvflow/lib/rl/resume_filter.py @@ -25,8 +25,8 @@ Optional ``max_num_samples`` truncates the input before filtering, so the original file is used directly without host-side copies. -Standalone script (stdlib only, no nvflow/Gym dependencies) that runs -inside the Slurm container with python3. +Standalone script that runs inside the Slurm container with +``PYTHONPATH=/workspace``. Usage: python -m nvflow.lib.rl.resume_filter [max_num_samples] @@ -41,11 +41,28 @@ import os import sys +from nvflow.utils import setup_logger + +logger = setup_logger(__name__) + + +def _normalize_input(inp: list) -> list: + """Strip fields added by the Responses API (e.g. ``type``) so that + fingerprints match between the original input file and the async output + where the API decorates each message with extra metadata.""" + normalized = [] + for msg in inp: + if isinstance(msg, dict): + normalized.append({k: v for k, v in msg.items() if k not in ("type",)}) + else: + normalized.append(msg) + return normalized + def fingerprint(row: dict) -> str: """Content-based hash for deduplication across async output order.""" rcp = row.get("responses_create_params", {}) - inp = rcp.get("input", []) + inp = _normalize_input(rcp.get("input", [])) ea = row.get("expected_answer", "") return hashlib.md5((json.dumps(inp, sort_keys=True) + "|" + str(ea)).encode()).hexdigest() @@ -63,7 +80,7 @@ def load_jsonl(path: str) -> list[dict]: except json.JSONDecodeError: dropped += 1 if dropped: - print(f"WARNING: Dropped {dropped} malformed line(s) in {path}") + logger.warning("Dropped %d malformed line(s) in %s", dropped, path) return rows @@ -75,15 +92,17 @@ def resume_filter( ) -> None: inputs = load_jsonl(input_file) if 0 < max_num_samples < len(inputs): - print(f"Truncating input from {len(inputs)} to {max_num_samples} rows (max_num_samples).") + logger.info( + "Truncating input from %d to %d rows (max_num_samples).", len(inputs), max_num_samples + ) inputs = inputs[:max_num_samples] if not os.path.exists(partial_file) or os.path.getsize(partial_file) == 0: - print(f"No partial output -- full run ({len(inputs)} rows).") + logger.info("No partial output -- full run (%d rows).", len(inputs)) with open(remaining_file, "w") as f: for r in inputs: f.write(json.dumps(r) + "\n") - print(f"RESUME_STATUS: remaining={len(inputs)} completed=0 total={len(inputs)}") + logger.info("RESUME_STATUS: remaining=%d completed=0 total=%d", len(inputs), len(inputs)) return completed = load_jsonl(partial_file) @@ -94,16 +113,19 @@ def resume_filter( for r in remaining: f.write(json.dumps(r) + "\n") - print( - f"RESUME_STATUS: remaining={len(remaining)} completed={len(completed)} total={len(inputs)}" + logger.info( + "RESUME_STATUS: remaining=%d completed=%d total=%d", + len(remaining), + len(completed), + len(inputs), ) if not remaining: - print("ALL_DONE") + logger.info("ALL_DONE") if __name__ == "__main__": if len(sys.argv) not in (4, 5): - print( + logger.error( "Usage: python -m nvflow.lib.rl.resume_filter " " [max_num_samples]" ) diff --git a/nvflow/lib/rl/rollout.py b/nvflow/lib/rl/rollout.py index df40f44..ce84a7f 100644 --- a/nvflow/lib/rl/rollout.py +++ b/nvflow/lib/rl/rollout.py @@ -31,15 +31,24 @@ from nemo_skills.pipeline.utils.scripts import BaseJobScript, ServerScript from nvflow.core import console +from nvflow.lib.vllm_compat import get_server_entrypoint from .helpers import ( + CONTAINER_CODE_DIR, + NON_VLLM_KEYS, + SERVER_CONTAINER, SHELL_FIND_FREE_PORT, + SHELL_READ_PORT_FILE, SHELL_WAIT_FOR_SERVER, + _build_overlay_setup_cmd, + _overlay_path, build_config_paths_str, build_judge_ng_run_overrides, build_vllm_server_args, + check_launcher_cwd, compute_num_gpus, determine_judge_mode, + get_env_from_environments, log_judge_details, resolve_host_path, ) @@ -68,6 +77,10 @@ class RolloutClientScript(BaseJobScript): **after** ``het_group_index`` has been assigned to all scripts, so ``hostname_ref()`` returns the correct Slurm shell variable for cross-node communication in heterogeneous jobs. + + vLLM ports are allocated dynamically on the compute node at runtime. + The server writes its port to a file on the shared filesystem; the + client waits for that file and reads the port before constructing URLs. """ policy_server: ServerScript | None = None @@ -88,20 +101,22 @@ class RolloutClientScript(BaseJobScript): num_parallel: int = 4 job_label: str = "" max_num_samples: int = 0 - - log_prefix: str = field(default="main", init=False) + chunk_id: int = 0 + num_chunks: int = 1 + responses_create_params: dict = field(default_factory=dict) + log_dir: str = "" def __post_init__(self): def build_cmd() -> str: if self.policy_server is not None: - policy_url = ( - f"http://{self.policy_server.hostname_ref()}:{self.policy_server.port}/v1" - ) + hostname = self.policy_server.hostname_ref() + policy_url = f"http://{hostname}:$POLICY_PORT/v1" else: policy_url = self.policy_base_url if self.judge_server is not None: - judge_url = f"http://{self.judge_server.hostname_ref()}:{self.judge_server.port}/v1" + judge_hostname = self.judge_server.hostname_ref() + judge_url = f"http://{judge_hostname}:$JUDGE_PORT/v1" judge_overrides = build_judge_ng_run_overrides( self.config, self.judge_mode, judge_url_var=judge_url ) @@ -109,7 +124,7 @@ def build_cmd() -> str: judge_url = "" judge_overrides = self.judge_ng_run_overrides - return _build_client_cmd( + cmd = _build_client_cmd( output_dir=self.output_dir, gym_path=self.gym_path, model_path=self.model_path, @@ -124,7 +139,18 @@ def build_cmd() -> str: judge_vllm_url=judge_url, judge_ng_run_overrides=judge_overrides, max_num_samples=self.max_num_samples, + chunk_id=self.chunk_id, + num_chunks=self.num_chunks, + responses_create_params=self.responses_create_params, + ) + + preamble = _build_port_read_preamble( + self.log_dir, + self.job_label, + has_policy=self.policy_server is not None, + has_judge=self.judge_server is not None, ) + return preamble + cmd self.set_inline(build_cmd) super().__post_init__() @@ -135,45 +161,112 @@ def build_cmd() -> str: # --------------------------------------------------------------------------- -# Keys stripped before building vLLM server_args: -# - Orchestration keys consumed by our pipeline (num_gpus, model_path, etc.) -# - NeMo-Gym keys consumed by build_judge_ng_run_overrides (uses_reasoning_parser) -# - Keys already emitted by nemo-skills' serve_vllm.py (tensor_parallel_size, -# trust_remote_code) -- passing them again causes duplicate-flag warnings. -_NON_VLLM_KEYS = frozenset( - { - "num_gpus", - "base_url", - "model_path", - "server_nodes", - "openai_base_url", - "openai_model", - "openai_api_key", - "tensor_parallel_size", - "trust_remote_code", - "uses_reasoning_parser", - } -) +def _vllm_port_file(log_dir: str, role: str, job_label: str = "") -> str: + """Return the shared-filesystem path for the dynamic vLLM port file. + + Includes ``$SLURM_JOB_ID`` so each Slurm job gets a unique file. This + avoids a race condition where the client's ``rm -f`` of stale port files + (after pip install) deletes the file the server already wrote. + """ + suffix = f"_{job_label}" if job_label else "" + return f"{log_dir}/.vllm_port_{role}{suffix}_${{SLURM_JOB_ID}}.txt" + +def _wrap_server_with_dynamic_port(script: ServerScript, role: str, port_file: str) -> None: + """Replace the hardcoded port in *script* with runtime-dynamic allocation. -def _make_server_script( + Wraps the server's inline command so that at runtime on the compute node: + 1. ``find_free_port()`` probes for an available port + 2. The port is written to *port_file* (shared filesystem) + 3. ``sed`` replaces the hardcoded port in the original command + """ + hardcoded = str(script.port) + original_inline = script.inline + wrapped = ( + f"{SHELL_FIND_FREE_PORT}\n" + f"VLLM_PORT=$(find_free_port)\n" + f'echo "[dynamic-port] {role} vLLM using port $VLLM_PORT (node ${{SLURM_NODEID:-0}})"\n' + f'if [ "${{SLURM_NODEID:-0}}" = "0" ]; then\n' + f' echo "$VLLM_PORT" > "{port_file}"\n' + f"fi\n" + f"ORIG_CMD=$(cat <<'__NVFLOW_VLLM_CMD__'\n" + f"{original_inline}\n" + f"__NVFLOW_VLLM_CMD__\n" + f")\n" + f'eval "$(echo "$ORIG_CMD" | sed "s/{hardcoded}/$VLLM_PORT/g")"\n' + ) + script.set_inline(wrapped) + + +def _build_port_read_preamble( + log_dir: str, + job_label: str, + *, + has_policy: bool = False, + has_judge: bool = False, +) -> str: + """Build bash preamble that reads dynamic vLLM ports from port files. + + Returns empty string if neither server is present. + """ + if not has_policy and not has_judge: + return "" + parts = [SHELL_READ_PORT_FILE] + if has_policy: + pf = _vllm_port_file(log_dir, "policy", job_label) + parts.append(f'POLICY_PORT=$(read_port_file "{pf}" "Policy vLLM" 300)') + if has_judge: + jf = _vllm_port_file(log_dir, "judge", job_label) + parts.append(f'JUDGE_PORT=$(read_port_file "{jf}" "Judge vLLM" 300)') + return "\n".join(parts) + "\n" + + +def make_server_script( vllm_cfg: dict[str, Any], cluster_config: dict, + *, + role: str = "policy", + log_dir: str = "", + job_label: str = "", ) -> ServerScript: if "num_gpus" not in vllm_cfg: raise ValueError("vLLM config must specify 'num_gpus'") - vllm_overrides = {k: v for k, v in vllm_cfg.items() if k not in _NON_VLLM_KEYS} - return ServerScript( + + model_path = vllm_cfg["model_path"] + hf_overrides = vllm_cfg.get("hf_config_overrides") + if hf_overrides: + overlay = _overlay_path(model_path, hf_overrides) + else: + overlay = None + + vllm_overrides = {k: v for k, v in vllm_cfg.items() if k not in NON_VLLM_KEYS} + if overlay: + # serve_vllm.py sets --served-model-name to --model (the overlay path). + # NeMo-Gym's proxy sends requests using the original model name, so we + # override served-model-name to keep the original identity. + vllm_overrides["served_model_name"] = model_path + script = ServerScript( server_type="vllm", - model_path=vllm_cfg["model_path"], + model_path=overlay or model_path, cluster_config=cluster_config, num_gpus=vllm_cfg["num_gpus"], num_nodes=vllm_cfg.get("server_nodes", 1), server_args=build_vllm_server_args(vllm_overrides), + server_entrypoint=vllm_cfg.get("server_entrypoint", get_server_entrypoint()), ) + if overlay: + setup_cmd = _build_overlay_setup_cmd(model_path, overlay, hf_overrides) + script.set_inline(f"{setup_cmd} && {script.inline}") + + if log_dir: + port_file = _vllm_port_file(log_dir, role, job_label) + _wrap_server_with_dynamic_port(script, role, port_file) + + return script -def _make_bash_script( + +def make_bash_script( bash_cmd: str, *, installation_command: str | None = None, @@ -187,56 +280,13 @@ def _make_bash_script( def _output_filename(seed: int, chunk_id: int) -> str: - return f"output-rs{seed}_chunk_{chunk_id}.jsonl" + return f"rs{seed}/chunk_{chunk_id}.jsonl" def _merged_filename(seed: int) -> str: return f"output-rs{seed}.jsonl" -# --------------------------------------------------------------------------- -# Input splitting -# --------------------------------------------------------------------------- - - -def _split_input( - input_path: Path, - chunks_dir: Path, - num_chunks: int, - max_num_samples: int = 0, -) -> tuple[int, int]: - """Split a JSONL file into *num_chunks* roughly equal chunk files. - - Returns ``(total, used)`` or ``(-1, -1)`` if chunks already exist (resume). - """ - chunk_files = [chunks_dir / f"input_chunk_{i}.jsonl" for i in range(num_chunks)] - - if all(f.exists() for f in chunk_files): - return -1, -1 - - for f in chunk_files: - f.unlink(missing_ok=True) - - chunks_dir.mkdir(parents=True, exist_ok=True) - with open(input_path) as f: - lines = f.readlines() - - total = len(lines) - if 0 < max_num_samples < total: - lines = lines[:max_num_samples] - used = len(lines) - - per_chunk = max(1, (used + num_chunks - 1) // num_chunks) - - for i, chunk_file in enumerate(chunk_files): - start = i * per_chunk - end = min(start + per_chunk, used) - with open(chunk_file, "w") as out: - out.writelines(lines[start:end]) - - return total, used - - # --------------------------------------------------------------------------- # Resume helpers # --------------------------------------------------------------------------- @@ -248,21 +298,53 @@ def _get_remaining_jobs( chunk_ids: list[int], rerun_done: bool, ) -> list[tuple[int, int]]: - """Return ``(seed, chunk)`` pairs that still need to run.""" + """Return ``(seed, chunk)`` pairs that still need to run. + + Two-level integrity check (see Fix 4 in the data-loss plan): + + 1. **Merged level** — if merged ``.done`` + merged data file both exist, + the seed is complete. Skip it even if chunk output files were + deleted by merge cleanup (that is the expected post-merge state). + If ``.done`` exists without a data file, the marker is stale — + delete it and fall through to chunk-level checks. + + 2. **Chunk level** — if a chunk ``.done`` exists but the chunk output + file is missing (and no successful merge), the marker is stale. + Delete it and re-schedule the chunk. + """ if rerun_done: for s in seeds: for c in chunk_ids: fname = _output_filename(s, c) (host_dir / f"{fname}.done").unlink(missing_ok=True) (host_dir / f"{fname}-async").unlink(missing_ok=True) + (host_dir / f"{fname}-async.prev").unlink(missing_ok=True) (host_dir / fname).unlink(missing_ok=True) return [(s, c) for s in seeds for c in chunk_ids] - return [ - (s, c) - for s in seeds - for c in chunk_ids - if not (host_dir / f"{_output_filename(s, c)}.done").exists() - ] + + remaining: list[tuple[int, int]] = [] + for s in seeds: + mf = _merged_filename(s) + merge_done = host_dir / f"{mf}.done" + merge_file = host_dir / mf + + if merge_done.exists(): + if merge_file.exists(): + continue + console.warning(f"Merged .done exists but {mf} is missing — resetting merge marker") + merge_done.unlink() + + for c in chunk_ids: + fname = _output_filename(s, c) + done = host_dir / f"{fname}.done" + output = host_dir / fname + if done.exists() and not output.exists(): + console.warning(f"Stale .done for {fname} — re-scheduling") + done.unlink() + if not done.exists(): + remaining.append((s, c)) + + return remaining # --------------------------------------------------------------------------- @@ -279,9 +361,9 @@ def _build_vllm_wait_snippet(policy_url: str, judge_url: str = "") -> str: het-group so we cannot check its PID, but ``kill -0 $$`` always succeeds, effectively skipping the "process died" early-exit while keeping curl polling. """ - snippet = f'wait_for_server "{policy_url}/models" "Policy vLLM" $$ 120 /dev/null\n' + snippet = f'wait_for_server "{policy_url}/models" "Policy vLLM" $$ 400 /dev/null\n' if judge_url: - snippet += f'wait_for_server "{judge_url}/models" "Judge vLLM" $$ 120 /dev/null\n' + snippet += f'wait_for_server "{judge_url}/models" "Judge vLLM" $$ 400 /dev/null\n' return snippet @@ -301,10 +383,23 @@ def _build_client_cmd( judge_vllm_url: str = "", judge_ng_run_overrides: str, max_num_samples: int = 0, + chunk_id: int = 0, + num_chunks: int = 1, + responses_create_params: dict | None = None, ) -> str: - wait_for_vllm = _build_vllm_wait_snippet(policy_vllm_url, judge_vllm_url) - - return ( + """Build the rollout collection bash script. + + Generated script structure: + 1. Wait for vLLM servers (policy + optional judge) + 1a. (If chunked) Extract this job's slice via head/tail + 1b. Self-heal: recover orphaned .prev / partial finalize from prior crash + 1c. Resume-filter: skip already-completed rows from prior partial run + 2. Start NeMo-Gym servers via ``ng_run`` (background) + 3. Collect rollouts via ``ng_collect_rollouts`` + Finalize: merge partials, cp→output, touch .done, cleanup temps + """ + # -- Shell variables & shared functions -------------------------------- + variables = ( "set -e\n" "\n" f'OUTPUT_DIR="{output_dir}"\n' @@ -319,20 +414,43 @@ def _build_client_cmd( f'JOB_LABEL="{job_label}"\n' f'VLLM_URL="{policy_vllm_url}"\n' f'JUDGE_URL="{judge_vllm_url}"\n' + f"CHUNK_ID={chunk_id}\n" + f"NUM_CHUNKS={num_chunks}\n" + ) + + setup = ( "\n" 'mkdir -p "$OUTPUT_DIR/logs"\n' "\n" + SHELL_FIND_FREE_PORT + "\n" - "HEAD_SERVER_PORT=$(find_free_port)\n" - "\n" 'NG_RUN_PID=""\n' "\n" "cleanup() {\n" + " local _nvflow_exit=$?\n" ' echo ""\n' ' echo "[Cleanup] Shutting down NeMo-Gym servers ..."\n' ' [ -n "$NG_RUN_PID" ] && kill $NG_RUN_PID 2>/dev/null && wait $NG_RUN_PID 2>/dev/null || true\n' + " # Best-effort merge of .prev into -async. On success, finalize already\n" + " # merged and removed .prev so this block is a no-op. On failure/kill,\n" + " # this is a first attempt; the self-heal at next startup is the guarantee.\n" + " # Chain with && so .prev is NEVER deleted unless the merge succeeds.\n" + ' if [ -n "$ASYNC_FILE" ] && [ -f "$ASYNC_FILE.prev" ] && [ "${PREV_MERGED:-0}" -eq 0 ]; then\n' + ' echo "[Cleanup] Restoring previous results into -async for resume ..."\n' + ' cat "$ASYNC_FILE.prev" > "$ASYNC_FILE.restored" \\\n' + ' && { [ ! -f "$ASYNC_FILE" ] || cat "$ASYNC_FILE" >> "$ASYNC_FILE.restored"; } \\\n' + ' && mv -f "$ASYNC_FILE.restored" "$ASYNC_FILE" \\\n' + ' && rm -f "$ASYNC_FILE.prev" \\\n' + ' || echo "[Cleanup] WARNING: merge failed — self-heal will recover on next start"\n' + " fi\n" + " if [ $_nvflow_exit -ne 0 ]; then\n" + ' echo "[nvflow] Client exited with code $_nvflow_exit — cancelling job ${SLURM_JOB_ID}"\n' + ' scancel "${SLURM_JOB_ID}" 2>/dev/null || kill 0 2>/dev/null || true\n' + " fi\n" "}\n" "trap cleanup EXIT\n" "\n" + SHELL_WAIT_FOR_SERVER + "\n" + ) + + banner = ( 'echo "============================================================"\n' 'echo "Rollout Collection [$JOB_LABEL]"\n' 'echo "============================================================"\n' @@ -343,27 +461,126 @@ def _build_client_cmd( 'echo "Policy URL: $VLLM_URL"\n' '[ -n "$JUDGE_URL" ] && echo "Judge URL: $JUDGE_URL"\n' 'echo "============================================================"\n' - "\n" - 'echo ""\n' - 'echo "[Step 1/3] Waiting for vLLM servers ..."\n' + wait_for_vllm + "\n" + ) + + # -- Step 1: Wait for vLLM servers ------------------------------------ + wait_for_vllm = _build_vllm_wait_snippet(policy_vllm_url, judge_vllm_url) + step1_wait = ( + '\necho ""\necho "[Step 1/3] Waiting for vLLM servers ..."\n' + wait_for_vllm + "\n" + ) + + # -- Step 1a: Logical chunking (extract this job's slice) -------------- + # When num_chunks > 1, each Slurm job extracts its portion of the full + # input at runtime via head|tail. No physical pre-splitting on the + # login node — keeps the launcher lightweight and filesystem-agnostic. + chunk_slice = ( + 'CHUNK_INPUT=""\n' + "if [ $NUM_CHUNKS -gt 1 ]; then\n" + ' echo ""\n' + ' echo "[Step 1a] Extracting chunk slice ..."\n' + ' TOTAL_LINES=$(wc -l < "$INPUT_DATA")\n' + " EFFECTIVE=$TOTAL_LINES\n" + f" MAX_SAMPLES={max_num_samples}\n" + " if [ $MAX_SAMPLES -gt 0 ] && [ $MAX_SAMPLES -lt $TOTAL_LINES ]; then\n" + " EFFECTIVE=$MAX_SAMPLES\n" + " fi\n" + " CHUNK_SIZE=$(( (EFFECTIVE + NUM_CHUNKS - 1) / NUM_CHUNKS ))\n" + " START_LINE=$(( CHUNK_ID * CHUNK_SIZE + 1 ))\n" + " END_LINE=$(( (CHUNK_ID + 1) * CHUNK_SIZE ))\n" + " [ $END_LINE -gt $EFFECTIVE ] && END_LINE=$EFFECTIVE\n" + ' CHUNK_INPUT="$OUTPUT_DIR/chunk_input_chunk$CHUNK_ID.jsonl"\n' + ' head -n $END_LINE "$INPUT_DATA" | tail -n +$START_LINE > "$CHUNK_INPUT"\n' + ' echo " Chunk $CHUNK_ID/$NUM_CHUNKS: lines $START_LINE-$END_LINE ($((END_LINE - START_LINE + 1)) samples)"\n' + ' INPUT_DATA="$CHUNK_INPUT"\n' + "fi\n" + ) + + # -- Early exit if already done ---------------------------------------- + # When dependent_jobs > 0, Slurm pre-submits a chain of jobs. If an + # earlier job in the chain already completed this chunk, the remaining + # dependent jobs should exit immediately instead of re-doing the work. + done_check = ( + 'if [ -f "$DONE_FILE" ]; then\n' + ' echo "Chunk already complete (.done exists) — skipping."\n' + " exit 0\n" + "fi\n" + ) + + # -- Step 1b: Self-heal ------------------------------------------------ + # Recover from any interrupted prior run so the resume filter sees the + # full set of completed samples. Three recovery cases: + # + # A. Partial finalize: output file exists but .done was never written + # (kill between mv -async→output and touch .done). + # Fix: move output back to -async. + # + # B. Orphaned .prev: cleanup trap was killed (SIGKILL / OOM / node + # failure) before merging .prev back into -async. + # Fix: merge .prev into -async. + # + # C. Orphaned temp files (.healed, .restored, .merged) from partial + # cleanup/finalize. Harmless but noisy — clean them up. + # + # Wrapped in a subshell so failures don't abort the job under set -e. + # If self-heal fails, the job continues (re-does some work, but runs). + selfheal = ( 'ASYNC_FILE="$OUTPUT_FILE-async"\n' - 'REMAINING_INPUT="$OUTPUT_DIR/remaining_input_$JOB_LABEL.jsonl"\n' + "(\n" + " # Case A: output exists without .done → restore to -async\n" + ' if [ -f "$OUTPUT_FILE" ] && [ ! -f "$DONE_FILE" ]; then\n' + ' echo "[Self-heal] Output file exists without .done — restoring to -async ..."\n' + ' mv -f "$OUTPUT_FILE" "$ASYNC_FILE"\n' + " fi\n" + "\n" + " # Case B: orphaned .prev → merge into -async\n" + ' if [ -f "$ASYNC_FILE.prev" ]; then\n' + ' echo "[Self-heal] Found orphaned .prev — merging into -async ..."\n' + ' PREV_LINES=$(wc -l < "$ASYNC_FILE.prev")\n' + " ASYNC_LINES=0\n" + ' [ -f "$ASYNC_FILE" ] && ASYNC_LINES=$(wc -l < "$ASYNC_FILE")\n' + ' cat "$ASYNC_FILE.prev" > "$ASYNC_FILE.healed"\n' + ' [ -f "$ASYNC_FILE" ] && cat "$ASYNC_FILE" >> "$ASYNC_FILE.healed"\n' + ' mv -f "$ASYNC_FILE.healed" "$ASYNC_FILE" && rm -f "$ASYNC_FILE.prev"\n' + ' MERGED_LINES=$(wc -l < "$ASYNC_FILE")\n' + ' echo " Recovered $PREV_LINES (prev) + $ASYNC_LINES (async) = $MERGED_LINES total rows"\n' + " fi\n" + "\n" + " # Case C: clean up orphaned temp files from prior crash\n" + ' rm -f "$ASYNC_FILE.healed" "$ASYNC_FILE.restored" "$ASYNC_FILE.merged"\n' + ') || echo "[Self-heal] WARNING: recovery failed — continuing with available data"\n' + ) + + # -- Step 1c: Resume filter (skip completed rows) --------------------- + # When chunked, truncation is handled by the slice above, so pass 0. + resume_max = 0 if num_chunks > 1 else max_num_samples + resume = ( + 'REMAINING_INPUT="$OUTPUT_DIR/remaining_input_chunk$CHUNK_ID.jsonl"\n' "\n" - f'if ! PYTHONPATH=/workspace python3 -m nvflow.lib.rl.resume_filter "$ASYNC_FILE" "$INPUT_DATA" "$REMAINING_INPUT" {max_num_samples}; then\n' + f'if ! PYTHONPATH={CONTAINER_CODE_DIR} python3 -m nvflow.lib.rl.resume_filter "$ASYNC_FILE" "$INPUT_DATA" "$REMAINING_INPUT" {resume_max}; then\n' ' echo "ERROR: resume_filter failed" >&2\n' " exit 1\n" "fi\n" "\n" 'if [ -f "$ASYNC_FILE" ] && [ ! -s "$REMAINING_INPUT" ]; then\n' ' echo "All rows already completed in -async -- finalizing."\n' - ' mv "$ASYNC_FILE" "$OUTPUT_FILE"\n' + ' cp -f "$ASYNC_FILE" "$OUTPUT_FILE"\n' ' touch "$DONE_FILE"\n' + ' rm -f "$ASYNC_FILE"\n' ' echo "Done [$JOB_LABEL]."\n' " exit 0\n" "fi\n" + ) + + # -- Step 2: Start NeMo-Gym servers ----------------------------------- + # WORKAROUND(port-toctou): allocate port here (not in setup) to minimise + # the window between find_free_port() and ng_run binding to it. + # WORKAROUND(gym-port-range): keep NeMo-Gym internal ports in 1024-8999, + # below the cluster ephemeral range (9000-65000 on ARM, 32768-60999 on x86). + step2_ng_run = ( + "\n" + "HEAD_SERVER_PORT=$(find_free_port)\n" "\n" 'cd "$GYM_PATH"\n' - "source .venv/bin/activate\n" "\n" 'echo ""\n' 'echo "[Step 2/3] Starting NeMo-Gym servers ..."\n' @@ -373,29 +590,124 @@ def _build_client_cmd( ' "+policy_model.responses_api_models.vllm_model.model=$MODEL_PATH" \\\n' ' "+head_server.host=127.0.0.1" \\\n' ' "+head_server.port=$HEAD_SERVER_PORT" \\\n' + ' "+port_range_low=1024" \\\n' + ' "+port_range_high=8999" \\\n' f"{judge_ng_run_overrides}" ' > "$OUTPUT_DIR/logs/ng_run_$JOB_LABEL.log" 2>&1 &\n' "NG_RUN_PID=$!\n" "\n" 'wait_for_server "http://127.0.0.1:$HEAD_SERVER_PORT/" "NeMo-Gym" $NG_RUN_PID 60 "$OUTPUT_DIR/logs/ng_run_$JOB_LABEL.log"\n' + ) + + # -- Step 3: Collect rollouts ----------------------------------------- + step3_collect = ( "\n" 'echo ""\n' 'echo "[Step 3/3] Collecting rollouts ..."\n' - "ng_collect_rollouts \\\n" - " +agent_name=$AGENT_NAME \\\n" - " +input_jsonl_fpath=$REMAINING_INPUT \\\n" - " +output_jsonl_fpath=$ASYNC_FILE \\\n" - " +num_repeats=1 \\\n" - " +num_samples_in_parallel=$NUM_PARALLEL \\\n" - " +head_server.host=127.0.0.1 \\\n" - " +head_server.port=$HEAD_SERVER_PORT\n" + "# Back up previous partial results before ng_collect_rollouts clears the file.\n" + 'ASYNC_BACKUP=""\n' + "PREV_MERGED=0\n" + 'if [ -s "$ASYNC_FILE" ]; then\n' + ' ASYNC_BACKUP="$ASYNC_FILE.prev"\n' + ' cp "$ASYNC_FILE" "$ASYNC_BACKUP"\n' + "fi\n" + "# Ensure clean slate for first attempt. Prior data is safe in .prev.\n" + "# Stale materialized_inputs from a prior Slurm job would cause\n" + "# resume_from_cache to load wrong task indexes.\n" + 'rm -f "$ASYNC_FILE"\n' + 'MATERIALIZED="$(dirname "$ASYNC_FILE")/$(basename "$ASYNC_FILE" .jsonl-async)_materialized_inputs.jsonl"\n' + 'rm -f "$MATERIALIZED"\n' + "# Retry loop: the vLLM tokenizer race condition (RuntimeError: Already\n" + "# borrowed) can crash the client on the initial request burst. Retrying\n" + "# after a short delay shifts the timing and almost always succeeds.\n" + "# resume_from_cache=true ensures retries skip completed samples.\n" + "_NVFLOW_MAX_RETRIES=3\n" + "_NVFLOW_RETRY_DELAY=15\n" + "_NVFLOW_EXIT=0\n" + "for _attempt in $(seq 1 $_NVFLOW_MAX_RETRIES); do\n" + " # Guard: truncate corrupted last line from SIGKILL mid-write\n" + ' if [ -f "$ASYNC_FILE" ] && [ -s "$ASYNC_FILE" ]; then\n' + ' if [ "$(tail -c 1 "$ASYNC_FILE" | xxd -p)" != "0a" ]; then\n' + ' head -n -1 "$ASYNC_FILE" > "$ASYNC_FILE.truncated" \\\n' + ' && mv -f "$ASYNC_FILE.truncated" "$ASYNC_FILE" \\\n' + ' || rm -f "$ASYNC_FILE.truncated"\n' + ' echo "[nvflow] Truncated corrupted last line from $ASYNC_FILE"\n' + " fi\n" + " fi\n" + " set +e\n" + " ng_collect_rollouts \\\n" + " ${AGENT_NAME:++agent_name=$AGENT_NAME} \\\n" + " +input_jsonl_fpath=$REMAINING_INPUT \\\n" + " +output_jsonl_fpath=$ASYNC_FILE \\\n" + " +num_repeats=1 \\\n" + " +resume_from_cache=true \\\n" + " +num_samples_in_parallel=$NUM_PARALLEL \\\n" + " +head_server.host=127.0.0.1 \\\n" + " +head_server.port=$HEAD_SERVER_PORT" + + "".join( + f" \\\n +responses_create_params.{k}={v}" + for k, v in (responses_create_params or {}).items() + ) + + "\n" + " _NVFLOW_EXIT=$?\n" + " set -e\n" + " [ $_NVFLOW_EXIT -eq 0 ] && break\n" + " if [ $_attempt -lt $_NVFLOW_MAX_RETRIES ]; then\n" + ' echo "[nvflow] ng_collect_rollouts exited $_NVFLOW_EXIT' + " (attempt $_attempt/$_NVFLOW_MAX_RETRIES)." + ' Retrying in ${_NVFLOW_RETRY_DELAY}s ..."\n' + " sleep $_NVFLOW_RETRY_DELAY\n" + " _NVFLOW_RETRY_DELAY=$((_NVFLOW_RETRY_DELAY * 2))\n" + " fi\n" + "done\n" + "if [ $_NVFLOW_EXIT -ne 0 ]; then\n" + ' echo "[nvflow] ng_collect_rollouts failed after' + ' $_NVFLOW_MAX_RETRIES attempts."\n' + " exit $_NVFLOW_EXIT\n" + "fi\n" + ) + + # -- Finalize: merge partials, write output, mark done ---------------- + # Order matters for crash safety: + # 1. Merge .prev + -async into -async (all results in one file) + # 2. Copy -async → output (cp, not mv — keeps -async as backup) + # 3. Touch .done (marks completion) + # 4. Clean up -async, .prev, temps (safe — .done exists) + # If killed at any point, self-heal on next start recovers: + # after 1: -async has everything, resume finds all done + # after 2: output exists w/o .done → self-heal Case A restores to -async + # after 3: .done exists → _get_remaining_jobs skips this chunk entirely + finalize = ( "\n" - 'mv "$ASYNC_FILE" "$OUTPUT_FILE"\n' - 'rm -f "$REMAINING_INPUT"\n' + "# Merge previous partial results with new results.\n" + 'if [ -n "$ASYNC_BACKUP" ] && [ -f "$ASYNC_BACKUP" ]; then\n' + ' cat "$ASYNC_BACKUP" "$ASYNC_FILE" > "$ASYNC_FILE.merged"\n' + ' mv -f "$ASYNC_FILE.merged" "$ASYNC_FILE"\n' + " PREV_MERGED=1\n" + ' rm -f "$ASYNC_BACKUP"\n' + "fi\n" + 'cp -f "$ASYNC_FILE" "$OUTPUT_FILE"\n' 'touch "$DONE_FILE"\n' + "# Safe to clean up — .done exists, chunk won't be rescheduled.\n" + 'rm -f "$ASYNC_FILE" "$REMAINING_INPUT"\n' + '[ -n "$CHUNK_INPUT" ] && rm -f "$CHUNK_INPUT"\n' 'echo "Done [$JOB_LABEL]. Cleanup via trap."\n' ) + return ( + variables + + setup + + banner + + done_check + + step1_wait + + chunk_slice + + selfheal + + resume + + step2_ng_run + + step3_collect + + finalize + ) + def _build_merge_cmd( *, @@ -410,7 +722,15 @@ def _build_merge_cmd( enrich_module: str, input_data: str, ) -> str: - return ( + """Build the chunk-merge + enrich + analyze bash script. + + Generated script structure: + 1. Concatenate per-chunk rollout files into a single merged file + 2. Enrich merged rollouts with input metadata + 3. Analyze rollouts (accuracy, token stats, etc.) + """ + # -- Shell variables -------------------------------------------------- + variables = ( "set -e\n" "\n" f'MERGED_FILE="{merged_file}"\n' @@ -418,43 +738,87 @@ def _build_merge_cmd( f'SEED_LABEL="{seed_label}"\n' f"NUM_CHUNKS={num_chunks}\n" f'INPUT_DATA="{input_data}"\n' + ) + + # -- Step 1: Merge chunks (atomic — write to .tmp, then mv) ------------ + chunk_done_pattern = f"{chunk_file_pattern}.done" + step1_merge = ( "\n" 'echo "============================================================"\n' 'echo "Merge Rollout Chunks [$SEED_LABEL]"\n' 'echo "============================================================"\n' "\n" - '> "$MERGED_FILE"\n' + "# Clean up stale temp file from a prior crashed merge\n" + 'rm -f "$MERGED_FILE.tmp"\n' + "\n" + "# Precondition: ALL chunk .done markers must exist\n" + "for i in $(seq 0 $((NUM_CHUNKS - 1))); do\n" + f' CHUNK_DONE="{chunk_done_pattern}"\n' + ' if [ ! -f "$CHUNK_DONE" ]; then\n' + ' echo "Chunk $i not complete (.done missing) — skipping merge."\n' + " exit 0\n" + " fi\n" + "done\n" + "\n" + 'echo "[Step 1/3] Merging chunk files ..."\n' + '> "$MERGED_FILE.tmp"\n' "for i in $(seq 0 $((NUM_CHUNKS - 1))); do\n" f' CHUNK_FILE="{chunk_file_pattern}"\n' - ' if [ ! -f "$CHUNK_FILE" ]; then\n' - ' echo "WARNING: Missing chunk file: $CHUNK_FILE"\n' - " continue\n" + ' if [ ! -f "$CHUNK_FILE" ] || [ ! -s "$CHUNK_FILE" ]; then\n' + ' echo "ERROR: chunk $i .done exists but file missing/empty — aborting."\n' + ' rm -f "$MERGED_FILE.tmp"\n' + " exit 1\n" " fi\n" ' LINES=$(wc -l < "$CHUNK_FILE")\n' ' echo " Chunk $i: $LINES lines"\n' - ' cat "$CHUNK_FILE" >> "$MERGED_FILE"\n' + ' cat "$CHUNK_FILE" >> "$MERGED_FILE.tmp"\n' "done\n" - 'TOTAL=$(wc -l < "$MERGED_FILE")\n' + "\n" + 'TOTAL=$(wc -l < "$MERGED_FILE.tmp")\n' 'echo " Merged total: $TOTAL lines"\n' + 'if [ "$TOTAL" -eq 0 ]; then\n' + ' echo "ERROR: All chunks present but 0 lines merged."\n' + ' rm -f "$MERGED_FILE.tmp"\n' + " exit 1\n" + "fi\n" "\n" - "for i in $(seq 0 $((NUM_CHUNKS - 1))); do\n" - f' CHUNK_FILE="{chunk_file_pattern}"\n' - ' rm -f "$CHUNK_FILE"\n' - "done\n" + "# Atomic replace — old merged file untouched until this point\n" + 'mv -f "$MERGED_FILE.tmp" "$MERGED_FILE"\n' + ) + + # -- Step 2: Enrich with input metadata ------------------------------- + step2_enrich = ( "\n" 'echo ""\n' 'echo "[Step 2/3] Enriching rollouts with input metadata ..."\n' - f"PYTHONPATH=/workspace python3 -m {enrich_module} \\\n" + f"PYTHONPATH={CONTAINER_CODE_DIR} python3 -m {enrich_module} \\\n" ' "$INPUT_DATA" \\\n' ' "$MERGED_FILE"\n' + ) + + # -- Step 3: Analyze rollouts ----------------------------------------- + step3_analyze = ( "\n" 'echo ""\n' 'echo "[Step 3/3] Analyzing rollouts ..."\n' - f"PYTHONPATH=/workspace python3 -m {analyze_module} \\\n" + f"PYTHONPATH={CONTAINER_CODE_DIR} python3 -m {analyze_module} \\\n" ' "$MERGED_FILE" \\\n' f' "$ANALYSIS_DIR"\n' + ) + + # -- Cleanup: mark done, then remove chunk output files ----------------- + # Order: touch .done FIRST, then delete chunk data. This guarantees the + # merged file is the verified complete copy before any source data is + # removed. Keep chunk .done markers — _get_remaining_jobs relies on them. + cleanup = ( "\n" f'touch "{merged_done_file}"\n' + "\n" + "# Safe cleanup: delete chunk data files only (keep .done markers).\n" + "for i in $(seq 0 $((NUM_CHUNKS - 1))); do\n" + f' CHUNK_FILE="{chunk_file_pattern}"\n' + ' rm -f "$CHUNK_FILE"\n' + "done\n" 'echo "Done [$SEED_LABEL]."\n' 'echo ""\n' 'echo "To browse rollouts interactively (requires Gym venv):"\n' @@ -462,300 +826,436 @@ def _build_merge_cmd( 'echo " ng_viewer +jsonl_fpath=$MERGED_FILE"\n' ) + return variables + step1_merge + step2_enrich + step3_analyze + cleanup -def _build_aggregate_cmd( + +def build_aggregate_cmd( *, rollout_dir: str, aggregate_module: str, + difficulty_filename: str = "difficulty.jsonl", ) -> str: return ( "set -e\n" 'echo "Cross-Seed Aggregation (pass@k)"\n' - f"PYTHONPATH=/workspace python3 -m {aggregate_module} \\\n" + f"PYTHONPATH={CONTAINER_CODE_DIR} python3 -m {aggregate_module} \\\n" f' "{rollout_dir}" \\\n' - f' "{rollout_dir}/aggregate"\n' + f' "{rollout_dir}/aggregate" \\\n' + f' --output_filename "{difficulty_filename}"\n' f'echo "Done. Results in {rollout_dir}/aggregate/"\n' ) -def _build_filter_cmd( +def build_filter_cmd( *, output_dir: str, difficulty_dir: str, filter_module: str, train_data: str, validation_data: str, - min_pass_rate: float = 0.0, - max_pass_rate: float = 1.0, + min_reward_std: float = 1e-6, + policy_model: str = "", + judge_model: str = "", + train_filename: str = "train.jsonl", + val_filename: str = "validation.jsonl", + difficulty_filename: str = "difficulty.jsonl", + report_filename: str = "filter_report.json", ) -> str: cmd = ( "set -e\n" - 'echo "Filter Training Data (reward-profile difficulty)"\n' - f"PYTHONPATH=/workspace python3 -m {filter_module} \\\n" + 'echo "Filter Training Data (reward-variance difficulty)"\n' + f"PYTHONPATH={CONTAINER_CODE_DIR} python3 -m {filter_module} \\\n" f' "{train_data}" \\\n' - f' "{difficulty_dir}/aggregate/difficulty.jsonl" \\\n' + f' "{difficulty_dir}/aggregate/{difficulty_filename}" \\\n' f' "{output_dir}" \\\n' - f" --min-pass-rate {min_pass_rate} \\\n" - f" --max-pass-rate {max_pass_rate}" + f" --min-reward-std {min_reward_std} \\\n" + f' --train-filename "{train_filename}" \\\n' + f' --val-filename "{val_filename}" \\\n' + f' --report-filename "{report_filename}"' ) if validation_data: cmd += f' \\\n --validation-data "{validation_data}"' + if policy_model: + cmd += f' \\\n --policy-model "{policy_model}"' + if judge_model: + cmd += f' \\\n --judge-model "{judge_model}"' cmd += "\n" cmd += f'echo "Done. Filtered data in {output_dir}/"\n' return cmd # --------------------------------------------------------------------------- -# Public API +# Config parsing # --------------------------------------------------------------------------- -def rollout( - config: dict[str, Any], - cluster: str, - expname: str, - run_after: list[str] | None = None, - *, - analyze_module: str = "", - enrich_module: str = "", - aggregate_module: str = "", - filter_module: str = "", -) -> None: - """Collect rollouts via NeMo-Gym, orchestrated through the nemo-skills Pipeline.""" - import nemo_skills.pipeline.utils as pipeline_utils - from nemo_skills.pipeline.utils.declarative import ( - Command, - CommandGroup, - HardwareConfig, - Pipeline, - ) - +@dataclass +class _RolloutParams: + """Parsed rollout configuration.""" + + output_dir: str + gym_path: str + client_container: str + post_container: str + server_container: str + installation_command: str | None + input_data: str + num_gpus: int + num_parallel: int + num_chunks: int + num_random_seeds: int + starting_seed: int + rerun_done: bool + dependent_jobs: int + max_num_samples: int + responses_create_params: dict + pcfg: dict + jcfg: dict + model_path: str + rcfg: dict + rcfg_with_env: dict + agent_name: str + config_paths_str: str + judge_mode: str + judge_ng_run_overrides: str + need_policy_server: bool + need_judge_server: bool + filter_cfg: dict + + +def _parse_rollout_config(config: dict[str, Any]) -> _RolloutParams: + """Unpack and validate rollout configuration.""" output_dir = config["output_dir"] gym_path = config["gym_path"] client_container = config["container"] - server_container = "vllm" + post_container = config.get("post_container", client_container) + server_container = SERVER_CONTAINER installation_command = config.get("installation_command") rcfg = config["rollout"] input_data = rcfg["input_data"] - agent_name = rcfg["agent_name"] num_gpus = compute_num_gpus(rcfg, has_policy=True) num_parallel = rcfg.get("num_samples_in_parallel", 4) num_chunks = rcfg.get("num_chunks", 1) num_random_seeds = rcfg.get("num_random_seeds", 1) starting_seed = rcfg.get("starting_seed", 0) rerun_done = rcfg.get("rerun_done", False) + dependent_jobs = rcfg.get("dependent_jobs", 0) max_num_samples = rcfg.get("max_num_samples") or 0 + responses_create_params = rcfg.get("responses_create_params") or {} pcfg = rcfg.get("policy_vllm") or {} jcfg = rcfg.get("judge_vllm") or {} model_path = pcfg["model_path"] - config_paths_str = build_config_paths_str(rcfg) + rcfg_with_env = {**rcfg, "environments": config["environments"]} + environment_name, env_inner_name, agent_name = get_env_from_environments(rcfg_with_env) + rcfg_with_env["environment_name"] = environment_name + rcfg_with_env["environment_inner_name"] = env_inner_name + + config_paths_str = build_config_paths_str(rcfg_with_env) judge_mode = determine_judge_mode(rcfg) - judge_ng_run_overrides = build_judge_ng_run_overrides(rcfg, judge_mode) + judge_ng_run_overrides = build_judge_ng_run_overrides(rcfg_with_env, judge_mode) need_policy_server = not pcfg.get("base_url") need_judge_server = judge_mode == "local_vllm" and bool(jcfg.get("model_path")) - cluster_config = pipeline_utils.get_cluster_config(cluster) + filter_cfg = config.get("filter") or {} - host_dir = resolve_host_path(output_dir) - host_dir.mkdir(parents=True, exist_ok=True) + return _RolloutParams( + output_dir=output_dir, + gym_path=gym_path, + client_container=client_container, + post_container=post_container, + server_container=server_container, + installation_command=installation_command, + input_data=input_data, + num_gpus=num_gpus, + num_parallel=num_parallel, + num_chunks=num_chunks, + num_random_seeds=num_random_seeds, + starting_seed=starting_seed, + rerun_done=rerun_done, + dependent_jobs=dependent_jobs, + max_num_samples=max_num_samples, + responses_create_params=responses_create_params, + pcfg=pcfg, + jcfg=jcfg, + model_path=model_path, + rcfg=rcfg, + rcfg_with_env=rcfg_with_env, + agent_name=agent_name, + config_paths_str=config_paths_str, + judge_mode=judge_mode, + judge_ng_run_overrides=judge_ng_run_overrides, + need_policy_server=need_policy_server, + need_judge_server=need_judge_server, + filter_cfg=filter_cfg, + ) - rollout_dir = f"{output_dir}/rollout" - host_rollout_dir = host_dir / "rollout" - host_rollout_dir.mkdir(parents=True, exist_ok=True) - seeds = list(range(starting_seed, starting_seed + num_random_seeds)) - chunk_ids = list(range(num_chunks)) +# --------------------------------------------------------------------------- +# Progress estimation +# --------------------------------------------------------------------------- - # -- Prepare input (split only when num_chunks > 1) ------------------ - if num_chunks == 1: - chunk_input_map = {0: input_data} - else: + +def _estimate_progress( + host_rollout_dir: Path, + seeds: list[int], + chunk_ids: list[int], + input_data: str, + max_num_samples: int, + num_chunks: int, +) -> str: + """Return progress string like '~42% (15,000/35,000 rows)', or '' on failure.""" + try: host_input = resolve_host_path(input_data) - if not host_input.exists(): - raise FileNotFoundError(f"Input file not found: {host_input}") - total, used = _split_input( - host_input, host_rollout_dir / "chunks", num_chunks, max_num_samples - ) - chunk_input_map = {i: f"{rollout_dir}/chunks/input_chunk_{i}.jsonl" for i in chunk_ids} - if total > 0: - detail = f"{used} lines -> {num_chunks} chunks" - if max_num_samples and used < total: - detail += f" (truncated from {total}, max_num_samples={max_num_samples})" - console.detail("Input", detail) + input_lines = sum(1 for _ in open(host_input)) if host_input.exists() else 0 + effective = min(input_lines, max_num_samples) if max_num_samples else input_lines + if effective <= 0: + return "" + chunk_size = (effective + num_chunks - 1) // num_chunks + completed_rows = 0 + total_rows = 0 + for seed, chunk_id in [(s, c) for s in seeds for c in chunk_ids]: + expected = max(0, min(chunk_size, effective - chunk_id * chunk_size)) + total_rows += expected + fname = _output_filename(seed, chunk_id) + if (host_rollout_dir / f"{fname}.done").exists(): + completed_rows += expected + else: + for suffix in ["-async", "-async.prev"]: + async_path = host_rollout_dir / f"{fname}{suffix}" + if async_path.exists(): + completed_rows += sum(1 for _ in open(async_path)) + if total_rows > 0: + pct = completed_rows / total_rows * 100 + return f"~{pct:.0f}% ({completed_rows:,}/{total_rows:,} rows in -async files)" + except Exception: + pass + return "" - # -- Resume: find remaining (seed, chunk) pairs --------------------- - remaining = _get_remaining_jobs(host_rollout_dir, seeds, chunk_ids, rerun_done) - skipped = len(seeds) * len(chunk_ids) - len(remaining) - console.status("Collecting rollouts (ng_collect_rollouts)") - console.detail("Model", model_path) - console.detail("Agent", agent_name) - if pcfg.get("base_url"): - console.detail("Policy vLLM", f"external ({pcfg['base_url']})") - else: - console.detail("Policy vLLM", f"local (GPUs={pcfg.get('num_gpus', 0)})") - log_judge_details(console, rcfg, judge_mode) - if need_policy_server and need_judge_server: - console.detail( - "Slurm GPUs/job", - f"{num_gpus} (policy={pcfg.get('num_gpus', 0)} + judge={jcfg.get('num_gpus', 0)}, het-group)", - ) - else: - console.detail("Slurm GPUs/job", str(num_gpus)) - console.detail( - "Jobs", - f"{len(remaining)} to submit, {skipped} done | {num_chunks} chunks x {num_random_seeds} seeds", - ) - console.detail("Output", output_dir) - console.blank() +# --------------------------------------------------------------------------- +# Job builders +# --------------------------------------------------------------------------- - filter_cfg = config.get("filter") or {} - if not remaining and not filter_cfg: - console.success("All rollout jobs already complete (use rerun_done to force).") - return +def _build_collection_jobs( + p: _RolloutParams, + remaining: list[tuple[int, int]], + cluster_config: dict, + rollout_dir: str, + expname: str, + run_after: list[str] | None, +) -> tuple[list[dict], dict[int, list[dict]]]: + """Build Slurm job specs for rollout collection. + + Returns ``(jobs, chunk_job_specs)`` where *chunk_job_specs* maps + seed -> list of final job specs (for merge dependency wiring). + """ + from nemo_skills.pipeline.utils.declarative import Command, CommandGroup, HardwareConfig - # -- Build Pipeline jobs --------------------------------------------- jobs: list[dict] = [] chunk_job_specs: dict[int, list[dict]] = {} + job_log_dir = f"{p.output_dir}/logs" + + sbatch_kwargs = None + raw_args = cluster_config.get("extra_sbatch_args") or [] + if raw_args: + sbatch_kwargs = {} + for arg in raw_args: + arg = arg.lstrip("-") + key, _, val = arg.partition("=") + sbatch_kwargs[key] = val if val else True for seed, chunk_id in remaining: job_lbl = f"rs{seed}_chunk{chunk_id}" out_filename = _output_filename(seed, chunk_id) + suffix_fmt = "-{dep_id}" if p.dependent_jobs > 0 else "" - policy_script = _make_server_script(pcfg, cluster_config) if need_policy_server else None - judge_script = _make_server_script(jcfg, cluster_config) if need_judge_server else None - - client_cmd = RolloutClientScript( - policy_server=policy_script, - judge_server=judge_script, - policy_base_url=pcfg.get("base_url", ""), - config=rcfg, - judge_mode=judge_mode, - judge_ng_run_overrides=judge_ng_run_overrides, - output_dir=rollout_dir, - gym_path=gym_path, - model_path=model_path, - agent_name=agent_name, - input_data=chunk_input_map[chunk_id], - output_file=f"{rollout_dir}/{out_filename}", - done_file=f"{rollout_dir}/{out_filename}.done", - config_paths=config_paths_str, - num_parallel=num_parallel, - job_label=job_lbl, - max_num_samples=max_num_samples if num_chunks == 1 else 0, - installation_command=installation_command, - ) + prev_job_spec = None + for dep_id in range(p.dependent_jobs + 1): + policy_script = ( + make_server_script( + p.pcfg, cluster_config, role="policy", log_dir=job_log_dir, job_label=job_lbl + ) + if p.need_policy_server + else None + ) + judge_script = ( + make_server_script( + p.jcfg, cluster_config, role="judge", log_dir=job_log_dir, job_label=job_lbl + ) + if p.need_judge_server + else None + ) - # Dual local servers -> het-group per server for dedicated GPUs. - if judge_script is not None and policy_script is not None: - policy_nodes = max(1, policy_script.num_nodes) - judge_nodes = max(1, judge_script.num_nodes) - primary_group = CommandGroup( - commands=[ - Command( - script=policy_script, container=server_container, name=f"{job_lbl}_policy" - ), - Command(script=client_cmd, container=client_container, name=job_lbl), - ], - hardware=HardwareConfig( - num_gpus=pcfg.get("num_gpus", 0), - num_nodes=policy_nodes, - ), - name=job_lbl, - log_dir=f"{output_dir}/logs", + client_cmd = RolloutClientScript( + policy_server=policy_script, + judge_server=judge_script, + policy_base_url=p.pcfg.get("base_url", ""), + config=p.rcfg_with_env, + judge_mode=p.judge_mode, + judge_ng_run_overrides=p.judge_ng_run_overrides, + output_dir=f"{rollout_dir}/rs{seed}", + gym_path=p.gym_path, + model_path=p.model_path, + agent_name=p.agent_name, + input_data=p.input_data, + output_file=f"{rollout_dir}/{out_filename}", + done_file=f"{rollout_dir}/{out_filename}.done", + config_paths=p.config_paths_str, + num_parallel=p.num_parallel, + job_label=job_lbl, + max_num_samples=p.max_num_samples, + chunk_id=chunk_id, + num_chunks=p.num_chunks, + responses_create_params=p.responses_create_params, + log_dir=job_log_dir, + installation_command=p.installation_command, ) - judge_group = CommandGroup( - commands=[ - Command( - script=judge_script, container=server_container, name=f"{job_lbl}_judge" + + job_deps: list = [prev_job_spec] if prev_job_spec is not None else (run_after or []) + suffix = suffix_fmt.format(dep_id=dep_id) + + # Dual local servers -> het-group per server for dedicated GPUs. + if judge_script is not None and policy_script is not None: + policy_nodes = max(1, policy_script.num_nodes) + judge_nodes = max(1, judge_script.num_nodes) + primary_group = CommandGroup( + commands=[ + Command( + script=policy_script, + container=p.server_container, + name=f"{job_lbl}_policy", + ), + Command(script=client_cmd, container=p.client_container, name=job_lbl), + ], + hardware=HardwareConfig( + num_gpus=p.pcfg.get("num_gpus", 0), + num_nodes=policy_nodes, + sbatch_kwargs=sbatch_kwargs, ), - ], - hardware=HardwareConfig( - num_gpus=jcfg.get("num_gpus", 0), - num_nodes=judge_nodes, - ), - name=f"{job_lbl}_judge", - log_dir=f"{output_dir}/logs", - ) - job_spec = { - "name": f"{expname}-rs{seed}-chunk{chunk_id}", - "groups": [primary_group, judge_group], - "dependencies": run_after if run_after else None, - } - else: - components: list[Command] = [] - max_nodes = 1 - if policy_script is not None: - components.append( - Command( - script=policy_script, container=server_container, name=f"{job_lbl}_policy" - ) + name=job_lbl, + log_dir=f"{p.output_dir}/logs", ) - max_nodes = max(max_nodes, policy_script.num_nodes) - if judge_script is not None: - components.append( - Command( - script=judge_script, container=server_container, name=f"{job_lbl}_judge" + judge_group = CommandGroup( + commands=[ + Command( + script=judge_script, + container=p.server_container, + name=f"{job_lbl}_judge", + ), + ], + hardware=HardwareConfig( + num_gpus=p.jcfg.get("num_gpus", 0), + num_nodes=judge_nodes, + sbatch_kwargs=sbatch_kwargs, + ), + name=f"{job_lbl}_judge", + log_dir=f"{p.output_dir}/logs", + ) + job_spec = { + "name": f"{expname}-rs{seed}-chunk{chunk_id}{suffix}", + "groups": [primary_group, judge_group], + "dependencies": job_deps, + } + else: + components: list[Command] = [] + max_nodes = 1 + if policy_script is not None: + components.append( + Command( + script=policy_script, + container=p.server_container, + name=f"{job_lbl}_policy", + ) ) + max_nodes = max(max_nodes, policy_script.num_nodes) + if judge_script is not None: + components.append( + Command( + script=judge_script, + container=p.server_container, + name=f"{job_lbl}_judge", + ) + ) + max_nodes = max(max_nodes, judge_script.num_nodes) + components.append( + Command(script=client_cmd, container=p.client_container, name=job_lbl) ) - max_nodes = max(max_nodes, judge_script.num_nodes) - components.append(Command(script=client_cmd, container=client_container, name=job_lbl)) - cmd_group = CommandGroup( - commands=components, - hardware=HardwareConfig( - num_gpus=num_gpus, - num_nodes=max_nodes, - ), - name=job_lbl, - log_dir=f"{output_dir}/logs", - ) - job_spec = { - "name": f"{expname}-rs{seed}-chunk{chunk_id}", - "group": cmd_group, - "dependencies": run_after if run_after else None, - } - jobs.append(job_spec) - chunk_job_specs.setdefault(seed, []).append(job_spec) + cmd_group = CommandGroup( + commands=components, + hardware=HardwareConfig( + num_gpus=p.num_gpus, + num_nodes=max_nodes, + sbatch_kwargs=sbatch_kwargs, + ), + name=job_lbl, + log_dir=f"{p.output_dir}/logs", + ) + job_spec = { + "name": f"{expname}-rs{seed}-chunk{chunk_id}{suffix}", + "group": cmd_group, + "dependencies": job_deps, + } + jobs.append(job_spec) + prev_job_spec = job_spec + + # Merge depends on the LAST job in each chunk's chain. + chunk_job_specs.setdefault(seed, []).append(prev_job_spec) + + return jobs, chunk_job_specs + + +def _build_merge_jobs( + p: _RolloutParams, + seeds: list[int], + chunk_job_specs: dict[int, list[dict]], + host_rollout_dir: Path, + rollout_dir: str, + expname: str, + *, + analyze_module: str, + enrich_module: str, +) -> list[dict]: + """Build one merge job per seed. Returns merge job specs.""" + from nemo_skills.pipeline.utils.declarative import Command, CommandGroup, HardwareConfig - # -- Merge jobs (one per seed, depends on that seed's chunks) -------- merge_job_specs: list[dict] = [] for seed in seeds: seed_label = f"rs{seed}" merged_filename = _merged_filename(seed) - if (host_rollout_dir / f"{merged_filename}.done").exists() and not rerun_done: + if (host_rollout_dir / f"{merged_filename}.done").exists() and not p.rerun_done: continue - chunk_pattern = f"{rollout_dir}/output-rs{seed}_chunk_$i.jsonl" + chunk_pattern = f"{rollout_dir}/rs{seed}/chunk_$i.jsonl" merge_cmd_str = _build_merge_cmd( - gym_path=gym_path, + gym_path=p.gym_path, merged_file=f"{rollout_dir}/{merged_filename}", analysis_dir=f"{rollout_dir}/analysis_{seed_label}", seed_label=seed_label, - num_chunks=num_chunks, + num_chunks=p.num_chunks, chunk_file_pattern=chunk_pattern, merged_done_file=f"{rollout_dir}/{merged_filename}.done", analyze_module=analyze_module, enrich_module=enrich_module, - input_data=input_data, + input_data=p.input_data, ) merge_cmd = Command( - script=_make_bash_script(merge_cmd_str), - container=client_container, + script=make_bash_script(merge_cmd_str), + container=p.post_container, name=f"merge-{seed_label}", ) merge_group = CommandGroup( commands=[merge_cmd], hardware=HardwareConfig(num_gpus=0), name=f"merge-{seed_label}", - log_dir=f"{output_dir}/logs", + log_dir=f"{p.output_dir}/logs", ) seed_deps = chunk_job_specs.get(seed, []) @@ -764,31 +1264,166 @@ def rollout( "group": merge_group, "dependencies": seed_deps if seed_deps else None, } - jobs.append(merge_job_spec) merge_job_specs.append(merge_job_spec) + return merge_job_specs + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def rollout( + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None = None, + *, + analyze_module: str = "", + enrich_module: str = "", + aggregate_module: str = "", + filter_module: str = "", +) -> None: + """Collect rollouts via NeMo-Gym, orchestrated through the nemo-skills Pipeline.""" + check_launcher_cwd() + + import nemo_skills.pipeline.utils as pipeline_utils + from nemo_skills.pipeline.utils.declarative import ( + Command, + CommandGroup, + HardwareConfig, + Pipeline, + ) + + p = _parse_rollout_config(config) + + cluster_config = pipeline_utils.get_cluster_config(cluster) + + rollout_dir = f"{p.output_dir}/rollout" + + # Resolve host paths for resume checks only. + # No file I/O on the login node — each Slurm job slices its own chunk + # at runtime via head/tail (see _build_client_cmd chunk_slice step). + host_dir = resolve_host_path(p.output_dir) + host_rollout_dir = host_dir / "rollout" + + seeds = list(range(p.starting_seed, p.starting_seed + p.num_random_seeds)) + chunk_ids = list(range(p.num_chunks)) + + if p.num_chunks > 1: + console.detail("Input", f"{p.num_chunks} logical chunks (each job slices at runtime)") + + # -- Resume: find remaining (seed, chunk) pairs --------------------- + if host_rollout_dir.exists(): + remaining = _get_remaining_jobs(host_rollout_dir, seeds, chunk_ids, p.rerun_done) + else: + remaining = [(s, c) for s in seeds for c in chunk_ids] + skipped = len(seeds) * len(chunk_ids) - len(remaining) + + # Invalidate merge .done for any seed that has chunks to re-run, + # so the merge step doesn't skip it with stale results. + # Never delete the merged data file — it serves as backup until the + # next merge atomically overwrites it (Fix 2: safe invalidation). + rerun_seeds = {s for s, _ in remaining} + for s in rerun_seeds: + mf = _merged_filename(s) + (host_rollout_dir / f"{mf}.done").unlink(missing_ok=True) + + # -- Progress estimate: count completed rows in partial -async files --- + progress_msg = "" + if remaining and host_rollout_dir.exists(): + progress_msg = _estimate_progress( + host_rollout_dir, + seeds, + chunk_ids, + p.input_data, + p.max_num_samples, + p.num_chunks, + ) + + console.status("Collecting rollouts (ng_collect_rollouts)") + console.detail("Model", p.model_path) + console.detail("Agent", p.agent_name) + if p.pcfg.get("base_url"): + console.detail("Policy vLLM", f"external ({p.pcfg['base_url']})") + else: + console.detail("Policy vLLM", f"local (GPUs={p.pcfg.get('num_gpus', 0)})") + log_judge_details(console, p.rcfg, p.judge_mode) + if p.need_policy_server and p.need_judge_server: + console.detail( + "Slurm GPUs/job", + f"{p.num_gpus} (policy={p.pcfg.get('num_gpus', 0)} + judge={p.jcfg.get('num_gpus', 0)}, het-group)", + ) + else: + console.detail("Slurm GPUs/job", str(p.num_gpus)) + total_rollout_jobs = len(remaining) * (p.dependent_jobs + 1) + chain_info = f" x {p.dependent_jobs + 1} chained" if p.dependent_jobs > 0 else "" + console.detail( + "Jobs", + f"{total_rollout_jobs} to submit, {skipped} done | " + f"{p.num_chunks} chunks x {p.num_random_seeds} seeds{chain_info}", + ) + console.detail("Output", p.output_dir) + if progress_msg: + console.detail("Progress", progress_msg) + console.blank() + + if not remaining and not p.filter_cfg: + console.success("All rollout jobs already complete (use rerun_done to force).") + return + + # -- Build Pipeline jobs --------------------------------------------- + jobs, chunk_job_specs = _build_collection_jobs( + p, + remaining, + cluster_config, + rollout_dir, + expname, + run_after, + ) + + # -- Merge jobs (one per seed, depends on that seed's chunks) -------- + merge_job_specs = _build_merge_jobs( + p, + seeds, + chunk_job_specs, + host_rollout_dir, + rollout_dir, + expname, + analyze_module=analyze_module, + enrich_module=enrich_module, + ) + jobs.extend(merge_job_specs) + # -- Cross-seed aggregation job (pass@k) ---------------------------- # Always run aggregate when filter is requested (needs difficulty.jsonl), # or when there are multiple seeds for cross-seed metrics. - run_aggregate = aggregate_module and (num_random_seeds > 1 or filter_module) + run_aggregate = aggregate_module and (p.num_random_seeds > 1 or filter_module) agg_job_spec: dict | None = None if run_aggregate: - agg_cmd_str = _build_aggregate_cmd( + # Filename config is read from p.filter_cfg when a filter is + # configured (since aggregate output becomes filter's input); + # otherwise fall back to defaults so callers that only aggregate + # (no filter) still produce the canonical difficulty.jsonl. + difficulty_filename = (p.filter_cfg or {}).get("difficulty_filename", "difficulty.jsonl") + agg_cmd_str = build_aggregate_cmd( rollout_dir=rollout_dir, aggregate_module=aggregate_module, + difficulty_filename=difficulty_filename, ) agg_cmd = Command( - script=_make_bash_script(agg_cmd_str), - container=client_container, + script=make_bash_script(agg_cmd_str), + container=p.post_container, name="aggregate", ) agg_group = CommandGroup( commands=[agg_cmd], hardware=HardwareConfig(num_gpus=0), name="aggregate", - log_dir=f"{output_dir}/logs", + log_dir=f"{p.output_dir}/logs", ) agg_job_spec = { "name": f"{expname}-aggregate", @@ -798,27 +1433,32 @@ def rollout( jobs.append(agg_job_spec) # -- Filter job (CPU, depends on aggregate) -------------------------- - if filter_module and filter_cfg: - filter_cmd_str = _build_filter_cmd( - output_dir=output_dir, + if filter_module and p.filter_cfg: + filter_cmd_str = build_filter_cmd( + output_dir=p.output_dir, difficulty_dir=rollout_dir, filter_module=filter_module, - train_data=filter_cfg["input_data"], - validation_data=filter_cfg.get("validation_data", ""), - min_pass_rate=filter_cfg.get("min_pass_rate", 0.0), - max_pass_rate=filter_cfg.get("max_pass_rate", 1.0), + train_data=p.filter_cfg["input_data"], + validation_data=p.filter_cfg.get("validation_data", ""), + min_reward_std=p.filter_cfg.get("min_reward_std", 1e-6), + policy_model=p.model_path, + judge_model=p.jcfg.get("model_path", ""), + train_filename=p.filter_cfg.get("train_filename", "train.jsonl"), + val_filename=p.filter_cfg.get("val_filename", "validation.jsonl"), + difficulty_filename=p.filter_cfg.get("difficulty_filename", "difficulty.jsonl"), + report_filename=p.filter_cfg.get("report_filename", "filter_report.json"), ) filter_cmd = Command( - script=_make_bash_script(filter_cmd_str), - container=client_container, + script=make_bash_script(filter_cmd_str), + container=p.post_container, name="filter", ) filter_group = CommandGroup( commands=[filter_cmd], hardware=HardwareConfig(num_gpus=0), name="filter", - log_dir=f"{output_dir}/logs", + log_dir=f"{p.output_dir}/logs", ) filter_deps = [agg_job_spec] if agg_job_spec else (merge_job_specs or None) jobs.append( @@ -841,4 +1481,4 @@ def rollout( ) pipeline.run() - console.success(f"{len(jobs)} job(s) submitted -> {output_dir}/") + console.success(f"{len(jobs)} job(s) submitted -> {p.output_dir}/") diff --git a/nvflow/lib/rl/verify.py b/nvflow/lib/rl/verify.py index 4311c29..0f3fc71 100644 --- a/nvflow/lib/rl/verify.py +++ b/nvflow/lib/rl/verify.py @@ -41,16 +41,26 @@ from nvflow.core import console from .helpers import ( + CONTAINER_CODE_DIR, + SERVER_CONTAINER, SHELL_FIND_FREE_PORT, SHELL_WAIT_FOR_SERVER, build_config_paths_str, build_judge_ng_run_overrides, + check_launcher_cwd, compute_num_gpus, determine_judge_mode, + get_env_from_environments, log_judge_details, resolve_host_path, ) -from .rollout import _build_aggregate_cmd, _build_filter_cmd, _make_bash_script, _make_server_script +from .rollout import ( + _build_port_read_preamble, + build_aggregate_cmd, + build_filter_cmd, + make_bash_script, + make_server_script, +) # --------------------------------------------------------------------------- # Inline command builders @@ -71,7 +81,14 @@ def _build_verify_cmd( environment_name: str, judge_ng_run_overrides: str, ) -> str: - return ( + """Build the re-judge (verify) bash script. + + Generated script structure: + 1. Start NeMo-Gym servers via ``ng_run`` (judge only, no policy) + 2. Re-judge rollouts via ``verify_worker`` + """ + # -- Shell variables & shared functions -------------------------------- + variables = ( "set -e\n" "\n" f'OUTPUT_DIR="{output_dir}"\n' @@ -83,6 +100,9 @@ def _build_verify_cmd( f'NUM_PARALLEL="{num_parallel}"\n' f'JOB_LABEL="{job_label}"\n' f'ENVIRONMENT_NAME="{environment_name}"\n' + ) + + setup = ( "\n" 'mkdir -p "$OUTPUT_DIR/logs" "$OUTPUT_DIR/rejudge"\n' "\n" + SHELL_FIND_FREE_PORT + "\n" @@ -97,6 +117,9 @@ def _build_verify_cmd( "}\n" "trap cleanup EXIT\n" "\n" + SHELL_WAIT_FOR_SERVER + "\n" + ) + + banner = ( 'echo "============================================================"\n' 'echo "Compute Rewards (re-judge) [$JOB_LABEL]"\n' 'echo "============================================================"\n' @@ -105,6 +128,10 @@ def _build_verify_cmd( f'echo "Judge mode: {judge_mode}"\n' 'echo "Environment: $ENVIRONMENT_NAME"\n' 'echo "============================================================"\n' + ) + + # -- Step 1: Start NeMo-Gym servers (judge only) ---------------------- + step1_ng_run = ( "\n" 'cd "$GYM_PATH"\n' "source .venv/bin/activate\n" @@ -122,22 +149,32 @@ def _build_verify_cmd( "NG_RUN_PID=$!\n" "\n" 'wait_for_server "http://127.0.0.1:$HEAD_SERVER_PORT/" "NeMo-Gym" $NG_RUN_PID 60 "$OUTPUT_DIR/logs/ng_run_$JOB_LABEL.log"\n' + ) + + # -- Step 2: Re-judge rollouts ---------------------------------------- + step2_rejudge = ( "\n" 'echo ""\n' 'echo "[Step 2/2] Re-judging rollouts ..."\n' - "PYTHONPATH=/workspace python3 -m nvflow.lib.rl.verify_worker \\\n" + f"PYTHONPATH={CONTAINER_CODE_DIR} python3 -m nvflow.lib.rl.verify_worker \\\n" ' "$INPUT_FILE" \\\n' ' "$OUTPUT_FILE-async" \\\n' ' "127.0.0.1" \\\n' ' "$HEAD_SERVER_PORT" \\\n' ' "$ENVIRONMENT_NAME" \\\n' ' "$NUM_PARALLEL"\n' + ) + + # -- Finalize: rename output, mark done ------------------------------- + finalize = ( "\n" 'mv "$OUTPUT_FILE-async" "$OUTPUT_FILE"\n' 'touch "$DONE_FILE"\n' 'echo "Done [$JOB_LABEL]. Cleanup via trap."\n' ) + return variables + setup + banner + step1_ng_run + step2_rejudge + finalize + def _build_analysis_cmd( *, @@ -158,7 +195,7 @@ def _build_analysis_cmd( for seed_label, rewards_file in analysis_entries: parts.append( f'echo "Analyzing {seed_label} ..."\n' - f"PYTHONPATH=/workspace python3 -m {analyze_module} \\\n" + f"PYTHONPATH={CONTAINER_CODE_DIR} python3 -m {analyze_module} \\\n" f' "{rewards_file}" \\\n' f' "{rejudge_dir}/analysis_{seed_label}" \\\n' ' "REWARD RE-COMPUTATION ANALYSIS"\n' @@ -208,6 +245,8 @@ def verify( filter_module: Python module for training data filtering (invoked as ``python3 -m ``). """ + check_launcher_cwd() + import nemo_skills.pipeline.utils as pipeline_utils from nemo_skills.pipeline.utils.declarative import ( Command, @@ -219,7 +258,8 @@ def verify( output_dir = config["output_dir"] gym_path = config["gym_path"] client_container = config["container"] - server_container = "vllm" + post_container = config.get("post_container", client_container) + server_container = SERVER_CONTAINER installation_command = config.get("installation_command") rcfg = config["rejudge"] @@ -227,11 +267,15 @@ def verify( num_parallel = rcfg.get("num_samples_in_parallel", 8) num_gpus = compute_num_gpus(rcfg, has_policy=False) rerun_done = rcfg.get("rerun_done", False) - environment_name = rcfg["environment_name"] + + rcfg_with_env = {**rcfg, "environments": config["environments"]} + environment_name, env_inner_name, _ = get_env_from_environments(rcfg_with_env) + rcfg_with_env["environment_name"] = environment_name + rcfg_with_env["environment_inner_name"] = env_inner_name judge_mode = determine_judge_mode(rcfg, allow_policy_as_judge=False) - config_paths_str = build_config_paths_str(rcfg) - judge_ng_run_overrides = build_judge_ng_run_overrides(rcfg, judge_mode) + config_paths_str = build_config_paths_str(rcfg_with_env) + judge_ng_run_overrides = build_judge_ng_run_overrides(rcfg_with_env, judge_mode) cluster_config = pipeline_utils.get_cluster_config(cluster) @@ -276,6 +320,7 @@ def verify( # -- Build Pipeline jobs --------------------------------------------- jcfg = rcfg.get("judge_vllm") or {} need_judge_server = judge_mode == "local_vllm" and jcfg.get("model_path") + job_log_dir = f"{output_dir}/logs" jobs: list[dict] = [] verify_job_specs: list[dict] = [] @@ -284,16 +329,26 @@ def verify( seed_label = rollout_file.stem.replace("output-", "") job_label = f"rejudge_{seed_label}" - judge_script = _make_server_script(jcfg, cluster_config) if need_judge_server else None + judge_script = ( + make_server_script( + jcfg, cluster_config, role="judge", log_dir=job_log_dir, job_label=job_label + ) + if need_judge_server + else None + ) if judge_script is not None: - judge_vllm_url = f"http://127.0.0.1:{judge_script.port}/v1" + judge_vllm_url = "http://127.0.0.1:$JUDGE_PORT/v1" job_judge_overrides = build_judge_ng_run_overrides( - rcfg, judge_mode, judge_url_var=judge_vllm_url + rcfg_with_env, judge_mode, judge_url_var=judge_vllm_url ) else: job_judge_overrides = judge_ng_run_overrides + port_preamble = _build_port_read_preamble( + job_log_dir, job_label, has_judge=judge_script is not None + ) + client_cmd_str = _build_verify_cmd( output_dir=output_dir, gym_path=gym_path, @@ -307,6 +362,8 @@ def verify( environment_name=environment_name, judge_ng_run_overrides=job_judge_overrides, ) + if port_preamble: + client_cmd_str = port_preamble + client_cmd_str components: list[Command] = [] max_nodes = 1 @@ -317,7 +374,7 @@ def verify( ) max_nodes = max(max_nodes, judge_script.num_nodes) - client_script = _make_bash_script( + client_script = make_bash_script( client_cmd_str, installation_command=installation_command, ) @@ -336,7 +393,7 @@ def verify( job_spec = { "name": f"{expname}-{seed_label}", "group": cmd_group, - "dependencies": run_after if run_after else None, + "dependencies": run_after or None, } jobs.append(job_spec) verify_job_specs.append(job_spec) @@ -355,8 +412,8 @@ def verify( ) analysis_cmd = Command( - script=_make_bash_script(analysis_cmd_str), - container=client_container, + script=make_bash_script(analysis_cmd_str), + container=post_container, name="analysis", ) analysis_group = CommandGroup( @@ -378,14 +435,14 @@ def verify( agg_job_spec: dict | None = None if run_aggregate: - agg_cmd_str = _build_aggregate_cmd( + agg_cmd_str = build_aggregate_cmd( rollout_dir=rejudge_dir, aggregate_module=aggregate_module, ) agg_cmd = Command( - script=_make_bash_script(agg_cmd_str), - container=client_container, + script=make_bash_script(agg_cmd_str), + container=post_container, name="aggregate", ) agg_group = CommandGroup( @@ -403,19 +460,18 @@ def verify( # -- Filter job (CPU, depends on aggregate) -------------------------- if filter_module and filter_cfg: - filter_cmd_str = _build_filter_cmd( + filter_cmd_str = build_filter_cmd( output_dir=output_dir, difficulty_dir=rejudge_dir, filter_module=filter_module, train_data=filter_cfg["input_data"], validation_data=filter_cfg.get("validation_data", ""), - min_pass_rate=filter_cfg.get("min_pass_rate", 0.0), - max_pass_rate=filter_cfg.get("max_pass_rate", 1.0), + min_reward_std=filter_cfg.get("min_reward_std", 1e-6), ) filter_cmd = Command( - script=_make_bash_script(filter_cmd_str), - container=client_container, + script=make_bash_script(filter_cmd_str), + container=post_container, name="filter", ) filter_group = CommandGroup( diff --git a/nvflow/lib/rl/verify_worker.py b/nvflow/lib/rl/verify_worker.py index 4cfb9dc..58cbed9 100644 --- a/nvflow/lib/rl/verify_worker.py +++ b/nvflow/lib/rl/verify_worker.py @@ -37,6 +37,10 @@ from nemo_gym.server_utils import ServerClient from tqdm.asyncio import tqdm +from nvflow.utils import setup_logger + +logger = setup_logger(__name__) + def _wait_for_server_client( head_host: str, @@ -75,7 +79,7 @@ async def _wait_for_verify_endpoint( json={}, ) if resp.status != 404: - print(f" /verify endpoint ready (HTTP {resp.status})") + logger.info(" /verify endpoint ready (HTTP %d)", resp.status) return except Exception: pass @@ -95,16 +99,16 @@ async def verify_rollouts( rollouts = [json.loads(line) for line in f if line.strip()] if not rollouts: - print("WARNING: No rollouts found in input file.") + logger.warning("No rollouts found in input file.") Path(output_file).write_text("") return - print(f"Connecting to NeMo-Gym head server at {head_host}:{head_port} ...") + logger.info("Connecting to NeMo-Gym head server at %s:%d ...", head_host, head_port) client = _wait_for_server_client(head_host, head_port) - print(f" Connected. Waiting for {environment_name} /verify endpoint ...") + logger.info(" Connected. Waiting for %s /verify endpoint ...", environment_name) await _wait_for_verify_endpoint(client, environment_name) - print(f"Re-judging {len(rollouts)} rollouts via {environment_name} /verify") + logger.info("Re-judging %d rollouts via %s /verify", len(rollouts), environment_name) max_retries = 3 retry_base_delay = 2.0 @@ -160,12 +164,15 @@ async def _verify(idx: int, rollout: dict) -> None: error_count += 1 if error_count <= 10: - print( - f"ERROR: /verify returned {last_status} for idx={idx} " - f"after {max_retries + 1} attempts: {last_body[:200]}" + logger.error( + "/verify returned %d for idx=%d after %d attempts: %s", + last_status, + idx, + max_retries + 1, + last_body[:200], ) elif error_count == 11: - print("ERROR: suppressing further per-record error messages ...") + logger.error("Suppressing further per-record error messages ...") results[idx] = None tasks = [_verify(i, r) for i, r in enumerate(rollouts)] @@ -178,21 +185,23 @@ async def _verify(idx: int, rollout: dict) -> None: f.write(json.dumps(r) + "\n") if error_count > 0: - print( - f"\nFATAL: {error_count}/{len(rollouts)} requests failed. " - f"Only {succeeded} results written." + logger.fatal( + "%d/%d requests failed. Only %d results written.", + error_count, + len(rollouts), + succeeded, ) sys.exit(1) rewards = [r.get("reward", 0.0) for r in results if r is not None] if rewards: avg = sum(rewards) / len(rewards) - print(f" Average reward: {avg:.4f} ({len(rewards)} samples)") + logger.info(" Average reward: %.4f (%d samples)", avg, len(rewards)) if __name__ == "__main__": if len(sys.argv) != 7: - print( + logger.error( "Usage: python -m nvflow.lib.rl.verify_worker " " " " " diff --git a/nvflow/lib/vllm_compat.py b/nvflow/lib/vllm_compat.py new file mode 100644 index 0000000..ed2b60f --- /dev/null +++ b/nvflow/lib/vllm_compat.py @@ -0,0 +1,123 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""vLLM compatibility helpers for standalone ``vllm serve`` processes. + +Routes all vLLM servers through a patched entrypoint that applies runtime +workarounds before the server starts. See ``scripts/serve_vllm_patched.py`` +for the full list of active workarounds. + +WORKAROUND(vllm-0.17-hermes, harmony-aarch64) + Remove this module (and serve_vllm_patched.py) once: + - vLLM ships hermes thread-safety fix (PR #35034), AND + - openai_harmony ships a fixed aarch64 binary (issue #71) +""" + +from __future__ import annotations + +# Path inside the container where the patched wrapper is mounted. +_PATCHED_SERVER_ENTRYPOINT = ( + "/nemo_run/code/scripts/serve_vllm_patched.py" # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) +) + + +def get_server_entrypoint() -> str: + """Return the patched vLLM server entrypoint path.""" + _ensure_ray_ports_patched() # WORKAROUND(nemo-skills-ray-ports) + return _PATCHED_SERVER_ENTRYPOINT + + +def inject_server_entrypoint(kwargs: dict, model_path: str = "", **_ignored) -> dict: + """Enrich *kwargs* with ``server_entrypoint`` for vLLM servers. + + Only injects when ``server_type`` is ``vllm`` or ``vllm_multimodal`` + (or absent, since vLLM is the nemo-skills default). Skips sglang / + trtllm / etc. + + If *kwargs* already contains ``server_entrypoint``, the caller's + explicit override is preserved. + """ + _ensure_ray_ports_patched() # WORKAROUND(nemo-skills-ray-ports) + server_type = kwargs.get("server_type", "vllm") + if server_type not in ("vllm", "vllm_multimodal"): + return kwargs + if "server_entrypoint" not in kwargs: + kwargs = {**kwargs, "server_entrypoint": _PATCHED_SERVER_ENTRYPOINT} + return kwargs + + +# --------------------------------------------------------------------------- +# WORKAROUND(nemo-skills-ray-ports) +# +# nemo-skills' get_ray_server_cmd hardcodes Ray worker ports at 14349-18349, +# which overlaps with the OS ephemeral port range (9000+ on HSG, 32768+ on +# standard Linux). vLLM's DP Coordinator allocates ZMQ ports from the +# ephemeral range, causing EADDRINUSE when a Ray worker already holds the +# same port. Observed on ~25% of multi-node (server_nodes > 1) launches. +# +# Fix: pin Ray worker ports to 6400-6999 — below both ephemeral floors and +# clear of all known services (NFS 2049, Redis/Ray-GCS 6379, vLLM 7000+). +# +# Only affects multi-node vLLM launches. Single-node configs (num_nodes=1) +# never invoke get_ray_server_cmd (guarded by nemo-skills server.py:174). +# +# Remove when: nemo-skills makes Ray ports configurable upstream. +# --------------------------------------------------------------------------- + + +def _patched_get_ray_server_cmd(start_cmd): + """Replacement for nemo_skills get_ray_server_cmd with safe port ranges.""" + ports = ( + "--node-manager-port=1301 " + "--object-manager-port=1303 " + "--dashboard-port=8265 " + "--dashboard-agent-grpc-port=1307 " + "--runtime-env-agent-port=1305 " + "--metrics-export-port=1309 " + "--min-worker-port=6400 " + "--max-worker-port=6999 " + ) + return ( + 'if [ "${SLURM_PROCID:-0}" = 0 ]; then ' + " echo 'Starting head node' && " + " export RAY_raylet_start_wait_time_s=120 && " + " ray start " + " --head " + " --port=6379 " + f" {ports} && " + f" {start_cmd} ; " + "else " + " echo 'Starting worker node' && " + " export RAY_raylet_start_wait_time_s=120 && " + ' echo "Connecting to head node at $SLURM_MASTER_NODE" && ' + " ray start " + " --block " + " --address=$SLURM_MASTER_NODE:6379 " + f" {ports} ;" + "fi" + ) + + +_patched_get_ray_server_cmd._nvflow_patched = True + + +def _ensure_ray_ports_patched(): + """Apply the Ray port fix lazily, on first call. Idempotent.""" + import nemo_skills.pipeline.utils as _ns_utils + import nemo_skills.pipeline.utils.server as _ns_server + + if getattr(_ns_server.get_ray_server_cmd, "_nvflow_patched", False): + return + _ns_server.get_ray_server_cmd = _patched_get_ray_server_cmd + _ns_utils.get_ray_server_cmd = _patched_get_ray_server_cmd diff --git a/nvflow/recipes/__init__.py b/nvflow/recipes/__init__.py index 283054d..2230155 100644 --- a/nvflow/recipes/__init__.py +++ b/nvflow/recipes/__init__.py @@ -12,17 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""NVFlow Recipes. +"""NVFlow Recipes -- auto-discovered from subdirectories. -Each recipe is a self-contained implementation for a specific domain: -- example: Example recipe for learning and testing -- finance: Financial reasoning models -- retail: Retail domain models (future) -- healthcare: Healthcare domain models (future) +Each recipe is a self-contained implementation for a specific domain. +Add a new recipe by creating ``nvflow/recipes//`` with an +``__init__.py`` that imports its stages tree. No manual edits to this +file are required. + +A recipe whose import fails will be reported on stderr via the +``nvflow.core.discovery`` helper but will not block discovery of the +others. """ -# Import all recipes to trigger stage registration -from . import ( - example, # noqa: F401 - finance, # noqa: F401 -) +from pathlib import Path + +from nvflow.core.discovery import import_stage_subpackages + +import_stage_subpackages(__package__, Path(__file__).parent) diff --git a/nvflow/recipes/example/stages/__init__.py b/nvflow/recipes/example/stages/__init__.py index b75f7b8..309c89a 100644 --- a/nvflow/recipes/example/stages/__init__.py +++ b/nvflow/recipes/example/stages/__init__.py @@ -20,7 +20,7 @@ # Import from subdirectories (sdg, data, etc.) _current_dir = Path(__file__).parent for subdir in _current_dir.iterdir(): - if subdir.is_dir() and not subdir.name.startswith("_"): + if subdir.is_dir() and not subdir.name.startswith(("_", ".")): try: importlib.import_module(f".{subdir.name}", package=__package__) except ImportError: diff --git a/nvflow/recipes/example/stages/sdg/generate_answer.py b/nvflow/recipes/example/stages/sdg/generate_answer.py index 78278cd..724f49c 100644 --- a/nvflow/recipes/example/stages/sdg/generate_answer.py +++ b/nvflow/recipes/example/stages/sdg/generate_answer.py @@ -64,10 +64,8 @@ def execute( console.detail("Experiment name", expname) console.blank() - # Prepare context with prompt config and inline arguments ctx = wrap_arguments(f"++prompt_config={prompt_config} {inline_args}") - # Submit generation job to cluster via nemo-skills console.detail("Stage kwargs", str(config.get("stage_kwargs", {}))) generate( diff --git a/nvflow/recipes/example/workflows/sdg_simple.yaml b/nvflow/recipes/example/workflows/sdg_simple.yaml index 76cd3f2..a0da143 100644 --- a/nvflow/recipes/example/workflows/sdg_simple.yaml +++ b/nvflow/recipes/example/workflows/sdg_simple.yaml @@ -10,7 +10,7 @@ workflow: cluster: my_cluster -# Repo is mounted at /workspace on cluster (see cluster_configs/nrt.yaml) +# Repo is mounted at /workspace on cluster (see cluster_configs/my_cluster.yaml) base_data_dir: /workspace/outputs/example/sdg_simple pipeline_stages: @@ -26,8 +26,8 @@ stages: dependencies: [] stage_kwargs: # Model server configuration - model: /hf_models/Qwen/Qwen3-4B-Instruct-2507 # Update with your model path - server_type: sglang # or vllm, trtllm + model: /hf_models/Qwen/Qwen3-4B + server_type: vllm # or sglang, trtllm server_gpus: 1 server_nodes: 1 @@ -35,5 +35,5 @@ stages: num_random_seeds: 1 num_chunks: 1 - # Slurm configuration (optional overrides) - partition: interactive + # Slurm configuration + # partition is resolved from cluster config (partition / cpu_partition) diff --git a/nvflow/recipes/finance/configs/smoke.yaml b/nvflow/recipes/finance/configs/smoke.yaml new file mode 100644 index 0000000..2525772 --- /dev/null +++ b/nvflow/recipes/finance/configs/smoke.yaml @@ -0,0 +1,13 @@ +# Smoke test configuration for SEC filing downloads +# Only 2 tickers, 1 year for minimal testing + +tickers: + - "NVDA" + - "AAPL" + +start_year: 2024 +end_year: 2024 + +forms: + - "10-K" + - "10-Q" diff --git a/nvflow/recipes/finance/configs/sp500.yaml b/nvflow/recipes/finance/configs/sp500.yaml index 547eba9..524d1b1 100644 --- a/nvflow/recipes/finance/configs/sp500.yaml +++ b/nvflow/recipes/finance/configs/sp500.yaml @@ -61,7 +61,7 @@ tickers: - "BAC" - "BAX" - "BDX" - - "BRK.B" + - "BRK-B" - "BBY" - "TECH" - "BIIB" @@ -76,7 +76,7 @@ tickers: - "AVGO" - "BR" - "BRO" - - "BF.B" + - "BF-B" - "BLDR" - "BG" - "BXP" @@ -139,7 +139,8 @@ tickers: - "DRI" - "DDOG" - "DVA" - - "DAY" + # DAY removed: Dayforce not yet in SEC registry + - "MRSH" # Marsh McLennan (formerly MMC) - "DECK" - "DE" - "DELL" @@ -254,7 +255,7 @@ tickers: - "ICE" - "IFF" - "IP" - - "IPG" + # IPG removed: Interpublic delisted after Omnicom merger - "INTU" - "ISRG" - "IVZ" @@ -268,7 +269,7 @@ tickers: - "JNJ" - "JCI" - "JPM" - - "K" + # K removed: Kellanova delisted after Mars acquisition - "KVUE" - "KDP" - "KEY" @@ -300,7 +301,7 @@ tickers: - "MTB" - "MPC" - "MAR" - - "MMC" + # MMC removed: Marsh McLennan rebranded to MRSH (added above) - "MLM" - "MAS" - "MA" diff --git a/nvflow/recipes/finance/data/__init__.py b/nvflow/recipes/finance/data/__init__.py index 2f197c5..9f46ecd 100644 --- a/nvflow/recipes/finance/data/__init__.py +++ b/nvflow/recipes/finance/data/__init__.py @@ -12,12 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Auto-discover data stages.""" +"""Auto-discover data modules.""" -import importlib from pathlib import Path -_current_dir = Path(__file__).parent -for file in _current_dir.glob("*.py"): - if file.stem != "__init__": - importlib.import_module(f".{file.stem}", package=__package__) +from nvflow.core.discovery import import_stage_modules + +import_stage_modules(__package__, Path(__file__).parent) diff --git a/nvflow/recipes/finance/datasets/finance_metrics.py b/nvflow/recipes/finance/datasets/finance_metrics.py index bf05e70..eb0c83a 100644 --- a/nvflow/recipes/finance/datasets/finance_metrics.py +++ b/nvflow/recipes/finance/datasets/finance_metrics.py @@ -46,15 +46,16 @@ def parse_rating(judgement: str) -> int | None: if not judgement: return None - # Look for [[0]], [[1]], or [[2]] - match = re.search(r"\[\[([012])\]\]", judgement) - if match: - return int(match.group(1)) - - # Fallback: try to find rating number in various formats - match = re.search(r"rating[:\s]+([012])", judgement.lower()) - if match: - return int(match.group(1)) + # Look for [[0]], [[1]], or [[2]] -- take the last match so reasoning + # judges that mention earlier ratings are recorded correctly. + matches = list(re.finditer(r"\[\[([012])\]\]", judgement)) + if matches: + return int(matches[-1].group(1)) + + # Fallback: try to find rating number in various formats (last match wins) + fallback = list(re.finditer(r"rating[:\s]+([012])", judgement.lower())) + if fallback: + return int(fallback[-1].group(1)) return None diff --git a/nvflow/recipes/finance/prompts/finance_openqa_judge.txt b/nvflow/recipes/finance/prompts/finance_openqa_judge.txt new file mode 100644 index 0000000..3c1c548 --- /dev/null +++ b/nvflow/recipes/finance/prompts/finance_openqa_judge.txt @@ -0,0 +1,98 @@ +===== System role ===== +You are an impartial financial analyst judge. Compare a candidate answer to a GOLD reference for a question about SEC filings, financial statements, or corporate disclosures. + +You are measuring correctness, NOT completeness. The candidate does not need to be equally detailed or equally helpful as the GOLD. A correct but concise answer is equivalent. + +Grading priorities (in order): +1) Factual correctness relative to GOLD. +2) Core claim coverage — the candidate must get the key insight right. + +Rules: +- Treat GOLD as authoritative for what counts as correct. +- Be forgiving of rounding errors as long as they are not essential: + Percentages ±0.5pp, dollar amounts ±2%, ratios ±2% relative. +- Accept equivalent representations: $1M = $1,000,000 = $1 million; + Q1 2023 = Jan-Mar 2023 = first quarter 2023; 0.5 = 50% = 1:2. +- For multi-part questions: equivalent if the candidate covers the main + finding or conclusion, even if some supporting details are missing. + NOT equivalent only if the candidate misses the central claim or + gives factually wrong information. +- If the candidate includes reasoning (e.g., in tags), focus + only on the final answer. +- If GOLD is a refusal (e.g., "Cannot determine from the given data"), + accept semantically equivalent refusals. +- If the candidate says it cannot answer but GOLD provides an answer, + they are NOT equivalent. +- Be concise. Do NOT reveal or rewrite the GOLD. + +Show your reasoning first, then provide the output. + +Output (at the end after double newlines): +- If equivalent: [[A=B]] they are equivalent +- If not equivalent: [[A!=B]] they are not equivalent + +===== Example 1 (equivalent — exact match) ===== +QUESTION: +What was Apple's revenue growth rate from 2022 to 2023? + +GOLD: +7.8% + +CANDIDATE: +Revenue grew by approximately 7.79%. + +The candidate provides the same growth rate with minor rounding (7.79% vs 7.8%, within tolerance). + +[[A=B]] they are equivalent + +===== Example 2 (equivalent — correct but less detailed) ===== +QUESTION: +What was the company's total compensation for the CEO in 2023? + +GOLD: +Total compensation was $10.5 million, comprising $3K base salary and $7.5K in stock awards, as shown in the proxy statement. + +CANDIDATE: +$10.5 million. + +The candidate gives the correct total. The breakdown detail is missing but the core factual claim matches. + +[[A=B]] they are equivalent + +===== Example 3 (not equivalent — wrong value) ===== +QUESTION: +What was the company's ROE for 2023? + +GOLD: +ROE = 15.2% + +CANDIDATE: +ROE = 8.5% + +The candidate provides a significantly different value (8.5% vs 15.2%). This is factually incorrect. + +[[A!=B]] they are not equivalent + +===== Example 4 (not equivalent — refuses when answer exists) ===== +QUESTION: +How could the tax dispute impact IBM's effective tax rate? + +GOLD: +The tax dispute could raise the effective tax rate if resolved unfavorably, increasing the provision for income taxes and reducing net income. + +CANDIDATE: +The document does not mention any tax disputes, so I cannot answer this question. + +The candidate claims the information is unavailable when GOLD provides a substantive answer. This is incorrect. + +[[A!=B]] they are not equivalent + +===== Inputs ===== +QUESTION: +{question} + +GOLD: +{expected_answer} + +CANDIDATE: +{generated_answer} diff --git a/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml b/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml index 5707709..b98272c 100644 --- a/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml +++ b/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml @@ -1,21 +1,24 @@ # ============================================================================ # Finance OpenQA Judge Overlay for NeMo-Gym equivalence_llm_judge # ============================================================================ -# Overrides the default STEM judge prompt with a finance-aware prompt that is -# more accurate for open-ended financial questions (Risk, Analysis, Comparison). +# Overrides the default STEM judge prompt file with a finance-aware prompt +# that is more accurate for open-ended financial questions (Risk, Analysis, +# Comparison). # -# This overlay only replaces judge_prompt_template. All other environment -# settings (swap check, per-record regex, reward_if_full_generation_succeeds, -# etc.) are inherited from the base equivalence_llm_judge.yaml. +# This overlay only replaces judge_prompt_template_fpath. All other +# environment settings (swap check, per-record regex, +# reward_if_full_generation_succeeds, etc.) are inherited from the base +# equivalence_llm_judge.yaml. # # Usage: add as the LAST entry in nemo_gym_config_paths so it overrides the -# base prompt (OmegaConf merge: last wins). +# base prompt file path (OmegaConf merge: last wins). # # nemo_gym_config_paths: # - responses_api_models/vllm_model/configs/vllm_model.yaml # - resources_servers/equivalence_llm_judge/configs/equivalence_llm_judge.yaml # - /workspace/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml # +# Prompt file: finance_openqa_judge.txt (same directory) # Adapted from: nvflow/recipes/finance/prompts/sec_judge.yaml # Key changes from the default STEM prompt: # - Domain: financial reasoning (SEC filings, ratios, risk factors) @@ -23,107 +26,13 @@ # - Rounding: forgiving of minor numerical differences # - Reasoning: ignores tags, focuses on final answer # - Examples: finance-specific (revenue, ratios, risk analysis) +# +# Note: upstream Gym commit 0123486b (Feb 2026) replaced the inline +# judge_prompt_template field with file-based judge_prompt_template_fpath. +# This overlay uses the file-based approach for compatibility. # ============================================================================ equivalence_llm_judge: resources_servers: equivalence_llm_judge: - judge_prompt_template: |- - ===== System role ===== - You are an impartial financial analyst judge. Compare a candidate answer to a GOLD reference for a question about SEC filings, financial statements, or corporate disclosures. - - You are measuring correctness, NOT completeness. The candidate does not need to be equally detailed or equally helpful as the GOLD. A correct but concise answer is equivalent. - - Grading priorities (in order): - 1) Factual correctness relative to GOLD. - 2) Core claim coverage — the candidate must get the key insight right. - - Rules: - - Treat GOLD as authoritative for what counts as correct. - - Be forgiving of rounding errors as long as they are not essential: - Percentages ±0.5pp, dollar amounts ±2%, ratios ±2% relative. - - Accept equivalent representations: $1M = $1,000,000 = $1 million; - Q1 2023 = Jan-Mar 2023 = first quarter 2023; 0.5 = 50% = 1:2. - - For multi-part questions: equivalent if the candidate covers the main - finding or conclusion, even if some supporting details are missing. - NOT equivalent only if the candidate misses the central claim or - gives factually wrong information. - - If the candidate includes reasoning (e.g., in tags), focus - only on the final answer. - - If GOLD is a refusal (e.g., "Cannot determine from the given data"), - accept semantically equivalent refusals. - - If the candidate says it cannot answer but GOLD provides an answer, - they are NOT equivalent. - - Be concise. Do NOT reveal or rewrite the GOLD. - - Show your reasoning first, then provide the output. - - Output (at the end after double newlines): - - If equivalent: [[A=B]] they are equivalent - - If not equivalent: [[A!=B]] they are not equivalent - - ===== Example 1 (equivalent — exact match) ===== - QUESTION: - What was Apple's revenue growth rate from 2022 to 2023? - - GOLD: - 7.8% - - CANDIDATE: - Revenue grew by approximately 7.79%. - - The candidate provides the same growth rate with minor rounding (7.79% vs 7.8%, within tolerance). - - [[A=B]] they are equivalent - - ===== Example 2 (equivalent — correct but less detailed) ===== - QUESTION: - What was the company's total compensation for the CEO in 2023? - - GOLD: - Total compensation was $10.5 million, comprising $3K base salary and $7.5K in stock awards, as shown in the proxy statement. - - CANDIDATE: - $10.5 million. - - The candidate gives the correct total. The breakdown detail is missing but the core factual claim matches. - - [[A=B]] they are equivalent - - ===== Example 3 (not equivalent — wrong value) ===== - QUESTION: - What was the company's ROE for 2023? - - GOLD: - ROE = 15.2% - - CANDIDATE: - ROE = 8.5% - - The candidate provides a significantly different value (8.5% vs 15.2%). This is factually incorrect. - - [[A!=B]] they are not equivalent - - ===== Example 4 (not equivalent — refuses when answer exists) ===== - QUESTION: - How could the tax dispute impact IBM's effective tax rate? - - GOLD: - The tax dispute could raise the effective tax rate if resolved unfavorably, increasing the provision for income taxes and reducing net income. - - CANDIDATE: - The document does not mention any tax disputes, so I cannot answer this question. - - The candidate claims the information is unavailable when GOLD provides a substantive answer. This is incorrect. - - [[A!=B]] they are not equivalent - - ===== Inputs ===== - QUESTION: - {question} - - GOLD: - {expected_answer} - - CANDIDATE: - {generated_answer} + judge_prompt_template_fpath: /workspace/nvflow/recipes/finance/prompts/finance_openqa_judge.txt diff --git a/nvflow/recipes/finance/prompts/finance_sec_search_judge.yaml b/nvflow/recipes/finance/prompts/finance_sec_search_judge.yaml new file mode 100644 index 0000000..c4ec591 --- /dev/null +++ b/nvflow/recipes/finance/prompts/finance_sec_search_judge.yaml @@ -0,0 +1,170 @@ +# Finance SEC Search Judge Prompt Template (NVFlow override) +# +# 3-point scale: [[0]] wrong / ungrounded, [[1]] partial, [[2]] fully correct. +# Only [[2]] receives reward 1.0 during training (binary) or 1.0 (scaled); +# [[1]] receives 0.5 in scaled mode, 0.0 in binary mode. +# +# Grounding requirement (added to close the parametric-knowledge shortcut): +# the candidate must demonstrate evidence of having retrieved the relevant +# SEC filing. Answers that only restate general financial concepts without +# filing-specific figures, dates, or disclosures are rated [[0]] — regardless +# of conceptual correctness. This complements the verify endpoint's hard gate +# (which requires a submit_final_result tool call) by also rejecting +# submit_final_result payloads that contain only textbook knowledge. +# +# Placeholders: {question}, {expected_answer}, {generated_answer} +# +# This file is referenced by the finance_sec_search_env overlay. +# Edit here to customise the judge prompt for NVFlow training runs. +# The content must match the Gym-expected YAML key: judge_prompt_template. + +judge_prompt_template: |- + You are a meticulous financial analyst grader evaluating answers to questions about SEC filings. The most common filing types are 10-K and 10-Q, but questions may also be answered from 8-K, DEF 14A (proxy), S-1, 20-F, 6-K, and other EDGAR filings — accept whichever filing type is actually relevant to the question. Compare a candidate answer to a GOLD reference and rate the response strictly. + + Questions may involve: + - Specific line items from financial statements + - Multi-year comparisons and trends + - Qualitative disclosures and risk factors + - Calculated metrics and ratios + + Grading priorities (in order): + + 1) Grounding — the candidate must demonstrate evidence of having consulted the actual SEC filing required by the question (whichever form type is relevant — 10-K, 10-Q, 8-K, proxy, S-1, 20-F, etc.). The agent has tools to search SEC EDGAR, download and parse filings, and retrieve information from them; a correct answer should look like the output of that workflow. Ask yourself: "Could this answer have been written without opening any SEC filing, using only general finance textbook knowledge?" If yes, the candidate did not consult the filing and must receive [[0]]. Signs that the filing was consulted include: specific dollar amounts and figures from the filing, period or filing dates (e.g., "Q1 FY2025", "as of December 31, 2024"), named line items from the financial statements, specific disclosure language, named note series (e.g., "4.75% notes due June 2023"), named individuals from proxy filings, or explicit source references. An answer that is conceptually correct but contains only general financial principles — with no filing-specific numbers, dates, or disclosures — is ungrounded. + + 2) Factual equivalence to GOLD (accept algebraically/formally equivalent formulations for financial ratios and calculations). + + 3) Completeness on required parts — the candidate must include the same core parts/subclaims as the GOLD. + + Rules: + + - Treat GOLD as authoritative for what counts as correct. + - Ungrounded answers (generic textbook explanations without filing-specific evidence) always receive [[0]], even when they restate the correct concepts. Conceptual overlap is not sufficient — the agent was given tools to retrieve real filings and was expected to use them. + - If GOLD is a range or set, the candidate is equivalent only if it lies within that range or is a member of that set. + - For financial ratios/calculations, accept mathematically identical transformations (e.g., 0.5 = 50% = 1:2). + - For numerical values, allow strict rounding differences: + • Percentages: ±0.1 percentage points (e.g., 7.8% ≈ 7.79%) + • Ratios/multipliers: ±1% relative difference (e.g., 2.5 ≈ 2.525) + • Dollar amounts: ±1% of the value (e.g., $100M ≈ $101M) + - For units: Accept equivalent representations ($1M = $1,000,000 = $1 million). + - For dates: Accept equivalent representations (Q1 2023 = Jan-Mar 2023 = first quarter 2023). + - If the candidate includes reasoning (e.g., in tags), focus on the final answer. + - If GOLD is a refusal (e.g., "Cannot determine"), accept semantically equivalent refusals from candidate. + - Multi-part answers: all essential parts must match for full credit; missing parts reduce the rating. + - Be concise. Do NOT reveal or rewrite the GOLD. + + After your explanation, you must rate the response on a scale of 0 to 2 by strictly following this format: [[rating]], for example: The rating is: [[1]], or: My rating is [[0]]. + + Rating criteria: + - [[0]] when the answer does not match the reference, is factually wrong, or is ungrounded (general financial knowledge without filing-specific figures, dates, or disclosures). + - [[1]] when the answer is grounded in the filing AND partially correct (correct number but missing explanation, or close but not exact). + - [[2]] when the answer is grounded in the filing AND fully correct and complete. + + ===== Example 1 (rating 2 - fully correct, grounded in 10-K) ===== + + QUESTION: + + What was Apple's revenue growth rate from 2022 to 2023? + + GOLD: + + 7.8% + + CANDIDATE: + + Apple's net sales grew from $394.3B in FY2022 to $383.3B in FY2023, but adjusting for the extra 53rd week the comparable growth was 7.79% as disclosed in the 10-K. + + The candidate cites the specific filing figures ($394.3B, $383.3B, FY2022/FY2023) and arrives at 7.79%, matching GOLD (7.8%) within the ±0.1% tolerance. Answer is both grounded and factually correct. + + The rating is: [[2]] + + ===== Example 2 (rating 1 - partially correct, grounded in 10-K) ===== + + QUESTION: + + Calculate Microsoft's current ratio for FY2023 and explain what it indicates about the company's liquidity. + + GOLD: + + Current ratio = 2.5; This indicates strong short-term liquidity as current assets are 2.5x current liabilities, suggesting the company can easily meet short-term obligations. + + CANDIDATE: + + Per Microsoft's FY2023 10-K, current assets of $184.3B divided by current liabilities of $73.7B gives a current ratio of 2.5. + + The candidate is grounded in the filing (cites specific current assets and current liabilities figures from the FY2023 10-K) and the numerical calculation is correct, but the explanation of what the ratio indicates about liquidity is missing. + + The rating is: [[1]] + + ===== Example 3 (rating 0 - incorrect number, grounded in an 8-K) ===== + + QUESTION: + + By how many basis points did ADI beat or miss its Q1 FY2025 adjusted operating margin guidance at the midpoint? + + GOLD: + + 50 bps beat + + CANDIDATE: + + Per ADI's Q1 FY2025 earnings 8-K, the company reported adjusted operating margin of 40.5% vs. the midpoint of prior guidance (41.0%) — a 50 bps miss. + + The candidate is grounded (cites the Q1 FY2025 earnings 8-K and the specific 40.5% actual vs. 41.0% midpoint) but arrives at "50 bps miss" while GOLD says "50 bps beat". Directionally wrong, so factually incorrect. + + The rating is: [[0]] + + ===== Example 4 (rating 0 - ungrounded / generic knowledge) ===== + + QUESTION: + + How could concentration in senior notes across multiple maturities and the observed fair-value gaps increase interest-rate or refinancing risk for the company? + + GOLD: + + The company's debt is heavily weighted in a series of senior notes that mature at many different dates (2022-2027, 2034, etc.). Because most of those notes are recorded at amortized cost while their fair-value estimates are consistently below the carrying amounts (e.g., the 4.75% notes due June 2023 have a carrying amount of $951 million but a fair-value of $897 million), the portfolio shows a "fair-value gap" that amplifies both refinancing and interest-rate risk. + + CANDIDATE: + + The concentration of senior notes across multiple maturities and observed fair-value gaps can increase interest-rate and refinancing risks for a company through the following mechanisms: + + 1. **Refinancing Risk from Maturity Concentration**: If a company has a large portion of its senior notes maturing within a short timeframe, it faces heightened refinancing risk. This is because the company must raise capital to repay or refinance these obligations, which may be challenging if market conditions deteriorate (e.g., rising interest rates, reduced liquidity). + + 2. **Interest-Rate Risk from Fixed-Rate Debt**: Senior notes with fixed interest rates expose the company to interest-rate risk. If market rates rise, the fair value of these notes declines, creating gaps between their book value and market value. + + 3. **Fair-Value Gaps and Market Perception**: Significant fair-value gaps may signal market concerns about the company's credit quality, potentially triggering downgrades or higher borrowing costs. + + The candidate restates general financial concepts (refinancing risk from maturity concentration, fair-value gaps as a market signal, fixed-rate debt exposure) and its structure could have been written by any finance textbook. It does NOT reference any filing-specific evidence: no specific maturity years (e.g., "2022-2027, 2034"), no note series or coupon rate (e.g., "4.75% notes due June 2023"), no carrying amounts ($951 million), and no fair-value figures ($897 million). There is no sign the agent consulted the underlying SEC filing. This is ungrounded textbook knowledge. + + The rating is: [[0]] + + ===== Example 5 (rating 2 - correct with reasoning, grounded in 10-Q) ===== + + QUESTION: + + What was Apple's total revenue in Q1 2023? + + GOLD: + + $117.2 billion + + CANDIDATE: + + Looking at the 10-Q filing for Q1 2023, the consolidated statements of operations show net sales of $117,154 million. Converting to billions: $117,154M / 1,000 = $117.154B, which rounds to $117.2B. Apple's total revenue in Q1 2023 was $117.2 billion. + + The candidate is grounded (cites the Q1 2023 10-Q and the specific net sales figure $117,154 million from the consolidated statements of operations). The final answer $117.2 billion matches GOLD exactly. + + The rating is: [[2]] + + ===== Inputs ===== + + QUESTION: + + {question} + + GOLD: + + {expected_answer} + + CANDIDATE: + + {generated_answer} diff --git a/nvflow/recipes/finance/prompts/finance_sec_search_retrieval.yaml b/nvflow/recipes/finance/prompts/finance_sec_search_retrieval.yaml new file mode 100644 index 0000000..732291e --- /dev/null +++ b/nvflow/recipes/finance/prompts/finance_sec_search_retrieval.yaml @@ -0,0 +1,13 @@ +# Finance SEC Search Retrieval System Prompt (NVFlow override) +# +# Used by the retrieve_information tool to instruct the policy model +# when extracting data from SEC filing documents. +# +# This file is referenced by the finance_sec_search_env overlay. +# Edit here to customise the retrieval prompt for NVFlow training runs. +# The content must match the Gym-expected YAML key: retrieval_system_prompt. + +retrieval_system_prompt: |- + You are a document analysis assistant. Answer based ONLY on + the document text provided. If the information is not present, state + that clearly — do NOT guess or fabricate numbers. diff --git a/nvflow/recipes/finance/prompts/finance_sec_search_template_with_web.yaml b/nvflow/recipes/finance/prompts/finance_sec_search_template_with_web.yaml new file mode 100644 index 0000000..2b37348 --- /dev/null +++ b/nvflow/recipes/finance/prompts/finance_sec_search_template_with_web.yaml @@ -0,0 +1,209 @@ +# ============================================================================ +# Finance SEC Search + Web Search Agent Prompt Template +# ============================================================================ +# Purpose: Same as finance_sec_search_template_without_web.yaml but with an additional +# web_search tool for querying the public internet. +# +# Source of truth for tools: Gym/resources_servers/finance_sec_search/scripts/convert_questions.py +# +# Input Variables: +# - problem: Financial question to answer (from SDG pipeline) +# +# Note: This template intentionally omits {context}. The agent discovers +# SEC filings via tools rather than receiving pre-loaded context. +# +# Usage: Point prompt_template to this file in base.yaml or model config +# when the environment has tavily_api_key configured. +# This file is NOT referenced by default -- use it explicitly. +# ============================================================================ + +user: | + You are a financial agent. You are given a question and you need to answer it using the tools provided. + You will not be able to interact with the user or ask clarifications, you must answer the question only based on the information provided. + + You should answer all questions as if the current date is {current_date}. + + You will have access to a data storage system. You can use this system to store parsed contents of HTML pages retrieved from the web. + You can then use the retrieve_information tool to apply answer questions or gather information from the stored documents using LLM-based prompts. + This data storage system is designed to help you avoid context window issues. + + When you have the final answer, you should call the `submit_final_result` tool with it. Your submission will not be processed unless you call this tool. + + You should include any necessary step-by-step reasoning, justification, calculations, or explanation in your answer. You will be evaluated both on the accuracy of the final answer, and the correctness of the supporting logic. + + When possible, please provide any calculated answers to at least two decimal places (e.g. 18.78% rather than 19%). Please do not round intermediate steps in any calculations - you should only round your final answer. + + At the end of your answer, you should provide your sources in a dictionary with the following format: + {{{{ + "sources": [ + {{{{ + "url": "https://example.com", + "name": "Name of the source" + }}}}, + ... + ] + }}}} + + Question: + {problem} + +tools: + - type: function + name: sec_filing_search + description: >- + Search SEC EDGAR for company filings by stock ticker symbol. Returns + filing metadata entries (sorted by filing date, most recent first), + including filing_url, form type, and report_date. It does not contain + the full text of the filing. Use form_types, start_date, and end_date + to narrow results. + parameters: + type: object + properties: + ticker: + type: string + description: "Stock ticker symbol (e.g., 'AAPL', 'MSFT', 'NVDA')" + form_types: + type: array + description: >- + (optional) Limits search to specific EDGAR form types + (e.g., ['10-K'], ['10-Q', '8-K']). Default: all form types. + items: + type: string + start_date: + type: string + description: "(optional) Filter filings on or after this date (YYYY-MM-DD)" + end_date: + type: string + description: "(optional) Filter filings on or before this date (YYYY-MM-DD)" + required: + - ticker + strict: false + + - type: function + name: parse_html_page + description: >- + This tool is used to parse the contents of an HTML page and save it to + the agent's data storage system. The tool will retrieve the HTML page + from the URL provided, then parse it from HTML to plain text. Finally, + it will save it to the agent's data storage system under the key + provided. You can use the retrieve_information tool to later retrieve + information about the stored page. + parameters: + type: object + properties: + url: + type: string + description: "The URL of the HTML page to parse" + key: + type: string + description: "The key to use when saving the result in the conversation's data storage." + required: + - url + - key + strict: false + + - type: function + name: retrieve_information + description: |- + This tool allows you to retrieve data from previously saved documents from the agent's data storage system, by applying an LLM prompt to the stored document. + + To use the tool, you will need to provide a prompt. This prompt will include both the query to be sent to the LLM, as well as the keys of files you have previously saved to the data storage system. + + For example, if you want to analyze data stored under the key "financial_report", your prompt should look like the following: + "Analyze the following financial report and extract the revenue figures: {{financial_report}}" + + The {{key_name}} will be replaced with the full text of the document stored under that key before the query is sent. + + IMPORTANT: Your prompt MUST include at least one key from the data storage using this exact format: {{key_name}}. If you don't use this exact format with double braces, the tool will fail to retrieve the information. + + You can also optionally only pass *a portion* of each document to the LLM, rather than the entire document. This can be used to avoid token limit errors or improve efficiency. To do so, use the input_character_ranges parameter to specify which portions of documents to extract. For example, if "financial_report" contains "Annual Report 2023" and you specify: [{"key": "financial_report", "start": 1, "end": 6}], then only "nnual" will be inserted into the prompt (characters 1 through 5, as end is exclusive). + parameters: + type: object + properties: + prompt: + type: string + description: >- + The prompt that will be passed to the LLM. You MUST include at + least one data storage key in the format {{key_name}} - for + example: 'Summarize this 10-K filing: {{company_10k}}'. The + content stored under each key will replace the {{key_name}} + placeholder. + input_character_ranges: + type: array + description: >- + An optional list of character range specifications for extracting + only portions of documents. Each object should have 'key' (the + document key), 'start' (start character index, inclusive), and + 'end' (end character index, exclusive). By default, the full + document is used if this parameter is not provided or if a key + is not included in the list. + items: + type: object + properties: + key: + type: string + description: "The document key from data storage" + start: + type: integer + description: "The starting character index (inclusive)" + end: + type: integer + description: "The ending character index (exclusive)" + required: + - key + - start + - end + required: + - prompt + strict: false + + - type: function + name: submit_final_result + description: >- + Submits the final answer to the user. You should include your final + answer, as well as any necessary reasoning, justification, calculations, + and explanation. Finally, you should provide any sources used to answer + the question. + + You MUST use this tool to submit your final result. The user will not + see your response if you do not use this tool to submit. + You will not be able to continue working after this tool is called; + the conversation will be ended. + parameters: + type: object + properties: + final_result: + type: string + description: "The final result to submit to the agent" + required: + - final_result + strict: false + + - type: function + name: web_search + description: >- + Search the public internet for information. Each result will contain + a url, a title, and one excerpt taken directly from the page. + parameters: + type: object + properties: + search_query: + type: string + description: "The query to search for" + start_date: + type: string + description: "(optional) The start date for the search range in the format YYYY-MM-DD" + end_date: + type: string + description: "(optional) The end date for the search range in the format YYYY-MM-DD" + number_of_results: + type: integer + description: "(optional) The number of search results to return." + maximum: 20 + minimum: 1 + default: 10 + required: + - search_query + strict: false + +parallel_tool_calls: false diff --git a/nvflow/recipes/finance/prompts/finance_sec_search_template_without_web.yaml b/nvflow/recipes/finance/prompts/finance_sec_search_template_without_web.yaml new file mode 100644 index 0000000..d745a65 --- /dev/null +++ b/nvflow/recipes/finance/prompts/finance_sec_search_template_without_web.yaml @@ -0,0 +1,176 @@ +# ============================================================================ +# Finance SEC Search Agent Prompt Template +# ============================================================================ +# Purpose: Provide the agent prompt, tool definitions, and sampling parameters +# for the finance_sec_search GRPO environment. +# +# Source of truth for tools: Gym/resources_servers/finance_sec_search/scripts/convert_questions.py +# +# Input Variables: +# - problem: Financial question to answer (from SDG pipeline) +# +# Note: This template intentionally omits {context}. The agent discovers +# SEC filings via tools rather than receiving pre-loaded context. +# ============================================================================ + +user: | + You are a financial agent. You are given a question and you need to answer it using the tools provided. + You will not be able to interact with the user or ask clarifications, you must answer the question only based on the information provided. + + You should answer all questions as if the current date is {current_date}. + + You will have access to a data storage system. You can use this system to store parsed contents of HTML pages retrieved from the web. + You can then use the retrieve_information tool to answer questions or gather information from the stored documents using LLM-based prompts. + This data storage system is designed to help you avoid context window issues. + + When you have the final answer, you should call the `submit_final_result` tool with it. Your submission will not be processed unless you call this tool. + + You should include any necessary step-by-step reasoning, justification, calculations, or explanation in your answer. You will be evaluated both on the accuracy of the final answer, and the correctness of the supporting logic. + + When possible, please provide any calculated answers to at least two decimal places (e.g. 18.78% rather than 19%). Please do not round intermediate steps in any calculations - you should only round your final answer. + + At the end of your answer, you should provide your sources in a dictionary with the following format: + {{{{ + "sources": [ + {{{{ + "url": "https://example.com", + "name": "Name of the source" + }}}}, + ... + ] + }}}} + + Question: + {problem} + +tools: + - type: function + name: sec_filing_search + description: >- + Search SEC EDGAR for company filings by stock ticker symbol. Returns + filing metadata entries (sorted by filing date, most recent first), + including filing_url, form type, and report_date. It does not contain + the full text of the filing. Use form_types, start_date, and end_date + to narrow results. + parameters: + type: object + properties: + ticker: + type: string + description: "Stock ticker symbol (e.g., 'AAPL', 'MSFT', 'NVDA')" + form_types: + type: array + description: >- + (optional) Limits search to specific EDGAR form types + (e.g., ['10-K'], ['10-Q', '8-K']). Default: all form types. + items: + type: string + start_date: + type: string + description: "(optional) Filter filings on or after this date (YYYY-MM-DD)" + end_date: + type: string + description: "(optional) Filter filings on or before this date (YYYY-MM-DD)" + required: + - ticker + strict: false + + - type: function + name: parse_html_page + description: >- + This tool is used to parse the contents of an HTML page and save it to + the agent's data storage system. The tool will retrieve the HTML page + from the URL provided, then parse it from HTML to plain text. Finally, + it will save it to the agent's data storage system under the key + provided. You can use the retrieve_information tool to later retrieve + information about the stored page. + parameters: + type: object + properties: + url: + type: string + description: "The URL of the HTML page to parse" + key: + type: string + description: "The key to use when saving the result in the conversation's data storage." + required: + - url + - key + strict: false + + - type: function + name: retrieve_information + description: |- + This tool allows you to retrieve data from previously saved documents from the agent's data storage system, by applying an LLM prompt to the stored document. + + To use the tool, you will need to provide a prompt. This prompt will include both the query to be sent to the LLM, as well as the keys of files you have previously saved to the data storage system. + + For example, if you want to analyze data stored under the key "financial_report", your prompt should look like the following: + "Analyze the following financial report and extract the revenue figures: {{financial_report}}" + + The {{key_name}} will be replaced with the full text of the document stored under that key before the query is sent. + + IMPORTANT: Your prompt MUST include at least one key from the data storage using this exact format: {{key_name}}. If you don't use this exact format with double braces, the tool will fail to retrieve the information. + + You can also optionally only pass *a portion* of each document to the LLM, rather than the entire document. This can be used to avoid token limit errors or improve efficiency. To do so, use the input_character_ranges parameter to specify which portions of documents to extract. For example, if "financial_report" contains "Annual Report 2023" and you specify: [{"key": "financial_report", "start": 1, "end": 6}], then only "nnual" will be inserted into the prompt (characters 1 through 5, as end is exclusive). + parameters: + type: object + properties: + prompt: + type: string + description: >- + The prompt that will be passed to the LLM. You MUST include at + least one data storage key in the format {{key_name}} - for + example: 'Summarize this 10-K filing: {{company_10k}}'. The + content stored under each key will replace the {{key_name}} + placeholder. + input_character_ranges: + type: array + description: >- + An optional list of character range specifications for extracting + only portions of documents. Each object should have 'key' (the + document key), 'start' (start character index, inclusive), and + 'end' (end character index, exclusive). By default, the full + document is used if this parameter is not provided or if a key + is not included in the list. + items: + type: object + properties: + key: + type: string + description: "The document key from data storage" + start: + type: integer + description: "The starting character index (inclusive)" + end: + type: integer + description: "The ending character index (exclusive)" + required: + - key + - start + - end + required: + - prompt + strict: false + + - type: function + name: submit_final_result + description: >- + Submits the final answer to the user. You should include your final + answer, as well as any reasoning, justification, calculations, and + explanation. Finally, you should provide any sources used to answer + the question. You MUST use this tool to submit your final result. + The user will not see your response if you do not use this tool to + submit. You will not be able to continue working after this tool is + called; the conversation will be ended. + parameters: + type: object + properties: + final_result: + type: string + description: "The final result to submit to the agent" + required: + - final_result + strict: false + +parallel_tool_calls: false diff --git a/nvflow/recipes/finance/prompts/validate_questions.yaml b/nvflow/recipes/finance/prompts/validate_questions.yaml new file mode 100644 index 0000000..1e95079 --- /dev/null +++ b/nvflow/recipes/finance/prompts/validate_questions.yaml @@ -0,0 +1,70 @@ +user: | + You are a precise classifier that defaults to VALID when uncertain. Output `Answer: VALID` or `Answer: INVALID` on the final line. + + You are evaluating whether a financial question about SEC filings is REUSABLE + as a standalone question. These questions were originally generated alongside + a source document; that context is no longer available. Your job is to catch + questions that are structurally unusable on their own — NOT to judge difficulty, + style, complexity, or how "tool-friendly" they are. + + A downstream agent will see only the question text (no document context) and + has tools to search EDGAR by ticker or company name, download filings, and + extract information. Trust the agent to handle complex, qualitative, or + multi-step questions. Only flag questions that are obviously broken. + + A question is INVALID ONLY IF one of these is clearly true: + + 1. NO COMPANY IDENTIFIER: The question uses "the company", "this filing", or + "the table above" as the ONLY reference, with no company name or ticker + appearing anywhere in the question text. + + 2. PLACEHOLDER TEXT: The question contains unreplaced template text, e.g., + "[COMPANY]", "company A", "", "???", "None", or similar. + + 3. NONSENSE FRAGMENT: The question is not a coherent sentence or is obviously + corrupt. + + All other questions are VALID, even if they are: + - Slightly awkward in grammar + - Broad, qualitative, narrative, or multi-step + - Lacking a specific period or metric + - Using "the company" as a PRONOUN after naming the company + - Asking about risk factors, MD&A, or other qualitative disclosures + + When uncertain, output VALID. False positives (dropping good questions) are + more costly than false negatives. + + Output format: Reason on one or two short lines, then on the final line: + Answer: VALID + or + Answer: INVALID + + Examples: + + Question: "In Apple's 10-K for FY2023, what did the company report for R&D?" + Reason: Apple is named explicitly; "the company" is a pronoun. + Answer: VALID + + Question: "How did the company respond to supply chain pressures?" + Reason: No company named anywhere in the question. + Answer: INVALID + + Question: "What was company A's revenue in [YEAR]?" + Reason: Unreplaced placeholder text. + Answer: INVALID + + Question: "What was Apple's strongest segment in FY2024?" + Reason: Apple named, period specified. Vague "strongest" is fine - filings discuss segment performance. + Answer: VALID + + Question: "For NVDA, discuss the risks associated with geopolitical tensions affecting AI chip exports." + Reason: NVDA named; narrative/qualitative topic is in scope - Risk Factors section exists for this. + Answer: VALID + + Question: "Compare Microsoft's and Google's capital allocation strategy over 2022-2024." + Reason: Both companies named, period specified. Multi-step and qualitative is fine - the agent can retrieve both filings. + Answer: VALID + + --- + + Question: {problem} diff --git a/nvflow/recipes/finance/recipe.yaml b/nvflow/recipes/finance/recipe.yaml index 8717f2c..b35ed9a 100644 --- a/nvflow/recipes/finance/recipe.yaml +++ b/nvflow/recipes/finance/recipe.yaml @@ -4,10 +4,10 @@ # This file defines recipe-level settings for the finance recipe. # # Workflow order defines the logical execution sequence for the pipeline: -# 1. download-sec - Download SEC filings -# 2. template_based_sdg - Template-based synthetic data generation -# 3. document_grounded_sdg - Document-grounded data generation -# 4. eval - Baseline model evaluation (independent) +# 1. eval - Baseline model evaluation (run first to establish baseline) +# 2. download-sec - Download SEC filings +# 3. template_based_sdg - Template-based synthetic data generation +# 4. document_grounded_sdg - Document-grounded data generation # 5. sft - Supervised fine-tuning (includes checkpoint eval) # 6. grpo - RL training (includes checkpoint eval) # ============================================================================ @@ -17,9 +17,9 @@ description: "End-to-end pipeline for financial model training and evaluation" # Workflow execution order (logical sequence) workflow_order: - - download-sec # Step 1: Download SEC filings - - template_based_sdg # Step 2: Template-based synthetic data generation - - document_grounded_sdg # Step 3: Document-grounded data generation - - eval # Step 4: Baseline model evaluation + - eval # Step 1: Baseline model evaluation + - download-sec # Step 2: Download SEC filings + - template_based_sdg # Step 3: Template-based synthetic data generation + - document_grounded_sdg # Step 4: Document-grounded data generation - sft # Step 5: Supervised fine-tuning + checkpoint eval - grpo # Step 6: RL training + checkpoint eval diff --git a/nvflow/recipes/finance/stages/__init__.py b/nvflow/recipes/finance/stages/__init__.py index 4fd2059..e824b4f 100644 --- a/nvflow/recipes/finance/stages/__init__.py +++ b/nvflow/recipes/finance/stages/__init__.py @@ -20,7 +20,7 @@ # Import from subdirectories (shared, sdg, sft, rl, evaluation) _current_dir = Path(__file__).parent for subdir in _current_dir.iterdir(): - if subdir.is_dir() and not subdir.name.startswith("_"): + if subdir.is_dir() and not subdir.name.startswith(("_", ".")): try: importlib.import_module(f".{subdir.name}", package=__package__) except ImportError: diff --git a/nvflow/recipes/finance/stages/download/__init__.py b/nvflow/recipes/finance/stages/download/__init__.py index 1c43874..13adb01 100644 --- a/nvflow/recipes/finance/stages/download/__init__.py +++ b/nvflow/recipes/finance/stages/download/__init__.py @@ -19,6 +19,6 @@ _current_dir = Path(__file__).parent for file in _current_dir.glob("*.py"): - if file.stem.startswith("_"): + if file.name.startswith(".") or file.stem.startswith("_"): continue importlib.import_module(f".{file.stem}", package=__package__) diff --git a/nvflow/recipes/finance/stages/download/download_sec_filings.py b/nvflow/recipes/finance/stages/download/download_sec_filings.py index d1720c7..8c1942f 100644 --- a/nvflow/recipes/finance/stages/download/download_sec_filings.py +++ b/nvflow/recipes/finance/stages/download/download_sec_filings.py @@ -24,6 +24,7 @@ @StageRegistry.register(recipe="finance", workflow="download-sec", stage="sap-500") @StageRegistry.register(recipe="finance", workflow="download-sec", stage="demo") +@StageRegistry.register(recipe="finance", workflow="download-sec", stage="smoke") class DownloadSecFilingsStage(BaseStage): """Download SEC filings (10-K, 10-Q, 8-K) from EDGAR and extract sections.""" diff --git a/nvflow/recipes/finance/stages/evaluation/__init__.py b/nvflow/recipes/finance/stages/evaluation/__init__.py index 8ca7e17..a1e51dd 100644 --- a/nvflow/recipes/finance/stages/evaluation/__init__.py +++ b/nvflow/recipes/finance/stages/evaluation/__init__.py @@ -19,6 +19,6 @@ _current_dir = Path(__file__).parent for file in _current_dir.glob("*.py"): - if file.stem.startswith("_"): + if file.name.startswith(".") or file.stem.startswith("_"): continue importlib.import_module(f".{file.stem}", package=__package__) diff --git a/nvflow/recipes/finance/stages/evaluation/evaluate.py b/nvflow/recipes/finance/stages/evaluation/evaluate.py index d9fb4ac..763a7b5 100644 --- a/nvflow/recipes/finance/stages/evaluation/evaluate.py +++ b/nvflow/recipes/finance/stages/evaluation/evaluate.py @@ -36,6 +36,7 @@ from typing import Any from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.vllm_compat import inject_server_entrypoint def _normalize_args(args: str | None) -> str: @@ -43,6 +44,16 @@ def _normalize_args(args: str | None) -> str: return " ".join(args.split()) if args else "" +def _build_stage_kwargs(config: dict, model_path: str = "") -> dict: + """Build stage_kwargs with server_args and gpt-oss aarch64 workaround.""" + kwargs: dict[str, str] = {"server_args": config.get("server_args", "")} + if ep := config.get("server_entrypoint"): + kwargs["server_entrypoint"] = ep + return inject_server_entrypoint( + kwargs, model_path + ) # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) + + def _load_eval_base_config() -> dict: """Load shared evaluation settings from ``eval/base.yaml``. @@ -249,9 +260,7 @@ def _build_config_from_model(self, raw_config: dict[str, Any]) -> dict[str, Any] "server_nodes": raw_config.get("nodes", 1), "extra_args": raw_config.get("inference_args", ""), }, - "stage_kwargs": { - "server_args": raw_config.get("server_args", ""), - }, + "stage_kwargs": _build_stage_kwargs(raw_config, raw_config.get("path", "")), } def _prepare_model_for_eval( @@ -304,6 +313,14 @@ def _prepare_model_for_eval( hf_model_path, convert_log_dir = get_hf_output_paths(run_path, step) model_name = _resolve_model_name(config, rollouts.get("base_model", "")) + if "num_gpus" not in conversion_config: + from nemo_skills.pipeline.utils import get_cluster_config + + cluster_cfg = get_cluster_config(cluster) + default_gpus = cluster_cfg.get("gpus_per_node", 8) + else: + default_gpus = conversion_config["num_gpus"] + conversion_job = _submit_conversion_job( megatron_path=megatron_path, hf_output_path=hf_model_path, @@ -311,7 +328,7 @@ def _prepare_model_for_eval( model_name=model_name, cluster=cluster, expname=expname, - num_gpus=conversion_config.get("num_gpus", 8), + num_gpus=default_gpus, installation_command=conversion_config.get("installation_command"), run_after=run_after, ) @@ -389,34 +406,10 @@ def execute( "Example: datasets_dir: /workspace/nvflow/recipes/finance/datasets" ) - # Step 3: Skip-if-done check - # If all benchmark metrics already exist, skip this stage entirely. - benchmark_names = [b.split(":")[0] for b in benchmarks.split(",")] - remaining = [] - for bname in benchmark_names: - metrics_path = Path(output_dir) / "eval-results" / bname / "metrics.json" - if metrics_path.exists(): - console.info(f"Skipping {bname} — metrics.json already exists at {metrics_path}") - else: - remaining.append(bname) - - if not remaining: - console.success("All benchmarks already completed — nothing to submit") - return - - if len(remaining) < len(benchmark_names): - skipped = set(benchmark_names) - set(remaining) - console.info(f"Skipped {len(skipped)} completed benchmark(s): {', '.join(skipped)}") - benchmarks_to_run = ",".join( - b for b in benchmarks.split(",") if b.split(":")[0] in remaining - ) - else: - benchmarks_to_run = benchmarks - - # Step 4: Submit eval job + # Step 3: Submit eval job console.status("Evaluating on finance benchmarks") console.detail("Model", effective_model_path or server_address) - console.detail("Benchmarks", benchmarks_to_run) + console.detail("Benchmarks", benchmarks) console.detail("Output", output_dir) console.detail("Judge", judge_model or judge_server_address) if eval_run_after: @@ -429,11 +422,9 @@ def execute( "cluster": cluster, "output_dir": output_dir, "log_dir": f"{output_dir}/logs", - "benchmarks": benchmarks_to_run, + "benchmarks": benchmarks, "expname": expname, "data_dir": datasets_dir, - "extra_datasets": "nvflow/recipes/finance/datasets", - "extra_datasets_type": "local", "model": effective_model_path, "server_address": server_address, "server_type": server_type, @@ -559,13 +550,14 @@ def execute( for step in eval_steps: console.info(f"Evaluating checkpoint step {step} (format: {checkpoint_format})") - if checkpoint_format == "hf": - model_path = str(Path(checkpoint_path) / f"step_{step}" / "policy") + # Support "final" to evaluate the auto-converted final_hf_model + if str(step).lower() == "final": + model_path = str(Path(checkpoint_path) / "final_hf_model") stage_config = { "benchmarks": benchmarks_list, "datasets_dir": base_config.get("datasets_dir"), "judge": base_config.get("judge"), - "output_dir": f"{eval_output_dir}/step-{step}", + "output_dir": f"{eval_output_dir}/final", "rollouts": { "model": model_path, "skip_conversion": True, @@ -578,6 +570,23 @@ def execute( "server_args": config.get("server_args", ""), }, } + elif checkpoint_format == "hf": + model_path = str(Path(checkpoint_path) / f"step_{step}" / "policy") + stage_config = { + "benchmarks": benchmarks_list, + "datasets_dir": base_config.get("datasets_dir"), + "judge": base_config.get("judge"), + "output_dir": f"{eval_output_dir}/step-{step}", + "rollouts": { + "model": model_path, + "skip_conversion": True, + "server_type": config.get("server_type", "vllm"), + "server_gpus": config.get("gpus", 1), + "server_nodes": config.get("nodes", 1), + "extra_args": config.get("inference_args", ""), + }, + "stage_kwargs": _build_stage_kwargs(config, model_path), + } elif checkpoint_format == "fsdp": stage_config = { "_run_path": str(checkpoint_path), @@ -595,17 +604,10 @@ def execute( "server_nodes": config.get("nodes", 1), "extra_args": config.get("inference_args", ""), }, - "stage_kwargs": { - "server_args": config.get("server_args", ""), - }, + "stage_kwargs": _build_stage_kwargs(config), } else: run_path = Path(checkpoint_path) - from nvflow.recipes.finance.utils.evaluation.checkpoint_converter import ( - get_hf_output_paths, - ) - - hf_model_path, _ = get_hf_output_paths(run_path, step) conversion_config = base_config.get("conversion", {}) stage_config = { "_run_path": str(run_path), @@ -623,9 +625,7 @@ def execute( "server_nodes": config.get("nodes", 1), "extra_args": config.get("inference_args", ""), }, - "stage_kwargs": { - "server_args": config.get("server_args", ""), - }, + "stage_kwargs": _build_stage_kwargs(config, base_model or ""), } super().execute( @@ -651,9 +651,7 @@ def execute( "server_nodes": config.get("nodes", 1), "extra_args": config.get("inference_args", ""), }, - "stage_kwargs": { - "server_args": config.get("server_args", ""), - }, + "stage_kwargs": _build_stage_kwargs(config, baseline_model or ""), } super().execute( config=baseline_config, @@ -715,9 +713,7 @@ def execute( "server_nodes": config.get("nodes", 1), "extra_args": config.get("inference_args", ""), }, - "stage_kwargs": { - "server_args": config.get("server_args", ""), - }, + "stage_kwargs": _build_stage_kwargs(config, hf_model_path), } else: stage_config = { @@ -734,9 +730,7 @@ def execute( "server_nodes": config.get("nodes", 1), "extra_args": config.get("inference_args", ""), }, - "stage_kwargs": { - "server_args": config.get("server_args", ""), - }, + "stage_kwargs": _build_stage_kwargs(config, config.get("base_model", "")), } return super().execute( diff --git a/nvflow/recipes/finance/stages/rl/__init__.py b/nvflow/recipes/finance/stages/rl/__init__.py index df2afd3..6e48334 100644 --- a/nvflow/recipes/finance/stages/rl/__init__.py +++ b/nvflow/recipes/finance/stages/rl/__init__.py @@ -19,6 +19,6 @@ _current_dir = Path(__file__).parent for file in _current_dir.glob("*.py"): - if file.stem.startswith("_"): + if file.name.startswith(".") or file.stem.startswith("_"): continue importlib.import_module(f".{file.stem}", package=__package__) diff --git a/nvflow/recipes/finance/stages/rl/apply_prompt_template.py b/nvflow/recipes/finance/stages/rl/apply_prompt_template.py index 9970dc3..b5a2ae1 100644 --- a/nvflow/recipes/finance/stages/rl/apply_prompt_template.py +++ b/nvflow/recipes/finance/stages/rl/apply_prompt_template.py @@ -18,8 +18,10 @@ instruction + context + question) and optionally extracts the concise answer after a configurable prefix (e.g. "Answer:") from ``generation``. -This ensures the model receives the same structured prompt it was -SFT-trained on, and the judge evaluates against a clean expected answer. +Runs per-environment: each environment specifies its own +``prompt_template`` and ``answer_prefix`` in the ``environments`` dict. +Input is read from ``{input_dir}/{env_name}/chunks`` and output is +written to ``{output_dir}/{env_name}/``. """ from typing import Any @@ -29,7 +31,7 @@ @StageRegistry.register(recipe="finance", workflow="grpo", stage="apply_prompt_template") class ApplyPromptTemplateStage(BaseStage): - """Apply prompt template and extract expected answer. + """Apply prompt template and extract expected answer (per-environment). Runs ``prompt_template_applier.py`` inside a Slurm container (CPU-only). Reads chunked JSONL from data_transformation, writes processed chunks @@ -43,20 +45,91 @@ def execute( expname: str, run_after: list[str] | None = None, ) -> None: - """Submit the prompt template application Slurm job.""" - from nemo_skills.pipeline.cli import run_cmd, wrap_arguments + """Submit per-environment prompt template application Slurm jobs.""" + from nvflow.lib.rl.helpers import resolve_environments + + environments = resolve_environments(config) + base_input_dir = config["input_dir"] + base_output_dir = config["output_dir"] + + # Dynamic current_date knobs (all optional -- feature activates only + # when sec_metadata_parquet is set in config). + sec_metadata_parquet = config.get("sec_metadata_parquet") + raw_sdg_filename = config.get("raw_sdg_filename", "final_result.jsonl") + jitter_min_days = config.get("jitter_min_days", 1) + jitter_max_days = config.get("jitter_max_days", 60) + fallback_current_date = config.get("fallback_current_date", "2025-04-07") + parquet_accession_column = config.get("parquet_accession_column", "accession_number") + parquet_filing_date_column = config.get("parquet_filing_date_column", "filing_date") + + for env_name, env_cfg in environments.items(): + raw_train_data = env_cfg.get("raw_train_data") + if not raw_train_data: + console.warning(f"Skipping environment '{env_name}': no raw_train_data configured") + continue + prompt_template = env_cfg.get("prompt_template") + if not prompt_template: + console.warning(f"Skipping environment '{env_name}': no prompt_template configured") + continue + + answer_prefix = env_cfg.get("answer_prefix") + env_input_dir = f"{base_input_dir}/{env_name}/chunks" + env_output_dir = f"{base_output_dir}/{env_name}" + + console.status(f"Applying prompt template for environment: {env_name}") + console.detail("Input", env_input_dir) + console.detail("Output", env_output_dir) + console.detail("Template", prompt_template) + console.detail("Answer prefix", answer_prefix or "(none -- keep full generation)") + if sec_metadata_parquet: + console.detail("Dynamic current_date", "enabled") + console.detail(" Parquet", sec_metadata_parquet) + console.detail(" Raw SDG dir", raw_train_data) + console.detail(" Raw SDG file", raw_sdg_filename) + console.detail(" Jitter range (days)", f"[{jitter_min_days}, {jitter_max_days}]") + console.detail(" Fallback date", fallback_current_date) + else: + console.detail("Dynamic current_date", "disabled (sec_metadata_parquet not set)") + console.blank() - input_dir = config["input_dir"] - output_dir = config["output_dir"] - prompt_template = config["prompt_template"] - answer_prefix = config.get("answer_prefix") + self._submit_job( + input_dir=env_input_dir, + output_dir=env_output_dir, + prompt_template=prompt_template, + answer_prefix=answer_prefix, + sec_metadata_parquet=sec_metadata_parquet, + raw_sdg_source_dir=raw_train_data, + raw_sdg_filename=raw_sdg_filename, + jitter_min_days=jitter_min_days, + jitter_max_days=jitter_max_days, + fallback_current_date=fallback_current_date, + parquet_accession_column=parquet_accession_column, + parquet_filing_date_column=parquet_filing_date_column, + cluster=cluster, + expname=f"{expname}-{env_name}", + run_after=run_after, + ) - console.status("Applying prompt template and extracting expected answer") - console.detail("Input", input_dir) - console.detail("Output", output_dir) - console.detail("Template", prompt_template) - console.detail("Answer prefix", answer_prefix or "(none -- keep full generation)") - console.blank() + def _submit_job( + self, + *, + input_dir: str, + output_dir: str, + prompt_template: str, + answer_prefix: str | None, + sec_metadata_parquet: str | None, + raw_sdg_source_dir: str, + raw_sdg_filename: str, + jitter_min_days: int, + jitter_max_days: int, + fallback_current_date: str, + parquet_accession_column: str, + parquet_filing_date_column: str, + cluster: str, + expname: str, + run_after: list[str] | None, + ) -> None: + from nemo_skills.pipeline.cli import run_cmd, wrap_arguments cmd = ( f"python -m nvflow.recipes.finance.utils.rl.prompt_template_applier " @@ -65,6 +138,17 @@ def execute( ) if answer_prefix: cmd += f" --answer_prefix '{answer_prefix}'" + if sec_metadata_parquet: + cmd += ( + f" --sec_metadata_parquet '{sec_metadata_parquet}'" + f" --raw_sdg_source_dir '{raw_sdg_source_dir}'" + f" --raw_sdg_filename '{raw_sdg_filename}'" + f" --jitter_min_days {jitter_min_days}" + f" --jitter_max_days {jitter_max_days}" + f" --fallback_current_date '{fallback_current_date}'" + f" --parquet_accession_column '{parquet_accession_column}'" + f" --parquet_filing_date_column '{parquet_filing_date_column}'" + ) run_cmd( ctx=wrap_arguments(cmd), @@ -79,6 +163,8 @@ def execute( def validate_config(self, config: dict[str, Any]) -> None: """Check that all required fields are present.""" - for field in ("input_dir", "output_dir", "prompt_template"): + for field in ("input_dir", "output_dir"): if not config.get(field): raise ValueError(f"'{field}' is required in apply_prompt_template config") + if not config.get("environments"): + raise ValueError("'environments' is required in apply_prompt_template config") diff --git a/nvflow/recipes/finance/stages/rl/collect_rollouts.py b/nvflow/recipes/finance/stages/rl/collect_rollouts.py index 27ece30..af803e1 100644 --- a/nvflow/recipes/finance/stages/rl/collect_rollouts.py +++ b/nvflow/recipes/finance/stages/rl/collect_rollouts.py @@ -42,31 +42,69 @@ def execute( expname: str, run_after: list[str] | None = None, ) -> None: + from nvflow.lib.rl.helpers import resolve_environments from nvflow.lib.rl.rollout import rollout - rollout( - config, - cluster, - expname, - run_after, - analyze_module=f"{_UTILS}.analyze_rollouts", - enrich_module=f"{_UTILS}.enrich_rollouts", - aggregate_module=f"{_UTILS}.aggregate_seeds", - filter_module=f"{_UTILS}.filter_training_data", - ) + environments = resolve_environments(config) + base_output_dir = config["output_dir"] + prepare_data_dir = config["prepare_data_dir"] + + for env_name, env_cfg in environments.items(): + env_output_dir = f"{base_output_dir}/{env_name}" + env_prepare_dir = f"{prepare_data_dir}/{env_name}" + + env_config = { + **config, + "output_dir": env_output_dir, + "environments": {env_name: env_cfg}, + } + env_judge_vllm = env_cfg.get("judge_vllm") or {} + env_policy_vllm = env_cfg.get("policy_vllm") or {} + env_rcp = env_cfg.get("responses_create_params") or {} + + base_rollout = config.get("rollout", {}) + merged_policy_vllm = {**base_rollout.get("policy_vllm", {}), **env_policy_vllm} + merged_rcp = {**base_rollout.get("responses_create_params", {}), **env_rcp} + + env_config["rollout"] = { + **base_rollout, + "policy_vllm": merged_policy_vllm, + "responses_create_params": merged_rcp, + "input_data": f"{env_prepare_dir}/train.jsonl", + "prepare_data_dir": env_prepare_dir, + "judge_vllm": env_judge_vllm, + } + env_config["filter"] = { + **config.get("filter", {}), + "input_data": f"{env_prepare_dir}/train.jsonl", + # No pre-rollout validation.jsonl -- prepare_data now emits a + # single train.jsonl (the post-rollout train_validation_split + # stage produces the final val set). filter_training_data + # treats validation_path as optional. + } + + rollout( + env_config, + cluster, + f"{expname}-{env_name}", + run_after, + analyze_module=f"{_UTILS}.analyze_rollouts", + enrich_module=f"{_UTILS}.enrich_rollouts", + aggregate_module=f"{_UTILS}.aggregate_seeds", + filter_module=f"{_UTILS}.filter_training_data", + ) def validate_config(self, config: dict[str, Any]) -> None: from nvflow.lib.rl.helpers import determine_judge_mode, validate_judge_config - for field in ("output_dir", "gym_path", "container"): + for field in ("output_dir", "gym_path", "container", "prepare_data_dir"): if not config.get(field): raise ValueError(f"'{field}' is required in collect_rollouts config") - rcfg = config.get("rollout") or {} - for field in ("input_data", "agent_name", "environment_name", "nemo_gym_config_paths"): - if not rcfg.get(field): - raise ValueError(f"'rollout.{field}' is required in collect_rollouts config") + if not config.get("environments"): + raise ValueError("'environments' dict is required in collect_rollouts config") + rcfg = config.get("rollout") or {} pcfg = rcfg.get("policy_vllm") or {} if not pcfg.get("model_path") and not pcfg.get("base_url"): raise ValueError( @@ -74,5 +112,7 @@ def validate_config(self, config: dict[str, Any]) -> None: "'rollout.policy_vllm.base_url' (external server) is required" ) - determine_judge_mode(rcfg) - validate_judge_config(rcfg) + for _env_name, env_cfg in config["environments"].items(): + env_judge = {**rcfg, "judge_vllm": env_cfg.get("judge_vllm") or {}} + determine_judge_mode(env_judge) + validate_judge_config(env_judge) diff --git a/nvflow/recipes/finance/stages/rl/compute_rewards.py b/nvflow/recipes/finance/stages/rl/compute_rewards.py index 8ac4faa..35b3421 100644 --- a/nvflow/recipes/finance/stages/rl/compute_rewards.py +++ b/nvflow/recipes/finance/stages/rl/compute_rewards.py @@ -42,29 +42,66 @@ def execute( expname: str, run_after: list[str] | None = None, ) -> None: + from nvflow.lib.rl.helpers import resolve_environments from nvflow.lib.rl.verify import verify - verify( - config, - cluster, - expname, - run_after, - analyze_module=f"{_UTILS}.analyze_rollouts", - aggregate_module=f"{_UTILS}.aggregate_seeds", - filter_module=f"{_UTILS}.filter_training_data", - ) + environments = resolve_environments(config) + base_output_dir = config["output_dir"] + rollouts_dir = config["rollouts_dir"] + prepare_data_dir = config["prepare_data_dir"] + + for env_name, env_cfg in environments.items(): + env_output_dir = f"{base_output_dir}/{env_name}" + env_rollouts_dir = f"{rollouts_dir}/{env_name}" + env_prepare_dir = f"{prepare_data_dir}/{env_name}" + + env_config = { + **config, + "output_dir": env_output_dir, + "environments": {env_name: env_cfg}, + } + env_judge_vllm = env_cfg.get("judge_vllm") or {} + env_config["rejudge"] = { + **config.get("rejudge", {}), + "input_dir": f"{env_rollouts_dir}/rollout", + "prepare_data_dir": env_prepare_dir, + "judge_vllm": env_judge_vllm, + } + env_config["filter"] = { + **config.get("filter", {}), + "input_data": f"{env_prepare_dir}/train.jsonl", + # prepare_data emits a single train.jsonl; the post-rollout + # train_validation_split stage produces the final val set. + # filter_training_data treats validation_path as optional. + } + + verify( + env_config, + cluster, + f"{expname}-{env_name}", + run_after, + analyze_module=f"{_UTILS}.analyze_rollouts", + aggregate_module=f"{_UTILS}.aggregate_seeds", + filter_module=f"{_UTILS}.filter_training_data", + ) def validate_config(self, config: dict[str, Any]) -> None: - from nvflow.lib.rl.helpers import determine_judge_mode, validate_judge_config + from nvflow.lib.rl.helpers import ( + determine_judge_mode, + resolve_environments, + validate_judge_config, + ) - for field in ("output_dir", "gym_path", "container"): + for field in ("output_dir", "gym_path", "container", "rollouts_dir", "prepare_data_dir"): if not config.get(field): raise ValueError(f"'{field}' is required in compute_rewards config") - rcfg = config.get("rejudge") or {} - for field in ("input_dir", "environment_name", "nemo_gym_config_paths"): - if not rcfg.get(field): - raise ValueError(f"'rejudge.{field}' is required in compute_rewards config") + if not config.get("environments"): + raise ValueError("'environments' dict is required in compute_rewards config") - determine_judge_mode(rcfg, allow_policy_as_judge=False) - validate_judge_config(rcfg) + environments = resolve_environments(config) + rcfg = config.get("rejudge") or {} + for _env_name, env_cfg in environments.items(): + env_judge = {**rcfg, "judge_vllm": env_cfg.get("judge_vllm") or {}} + determine_judge_mode(env_judge, allow_policy_as_judge=False) + validate_judge_config(env_judge) diff --git a/nvflow/recipes/finance/stages/rl/convert_to_responses_api.py b/nvflow/recipes/finance/stages/rl/convert_to_responses_api.py index c3dad36..c56e146 100644 --- a/nvflow/recipes/finance/stages/rl/convert_to_responses_api.py +++ b/nvflow/recipes/finance/stages/rl/convert_to_responses_api.py @@ -12,7 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Convert Q&A data to NeMo-Gym Responses API format for GRPO training.""" +"""Convert Q&A data to NeMo-Gym Responses API format for GRPO training. + +Runs per-environment: reads from ``{input_dir}/{env_name}/`` and writes +to ``{output_dir}/{env_name}/final_result.jsonl``. +""" from typing import Any @@ -34,18 +38,49 @@ def execute( expname: str, run_after: list[str] | None = None, ) -> None: - """Submit the data conversion Slurm job.""" - from nemo_skills.pipeline.cli import run_cmd, wrap_arguments + """Submit per-environment data conversion Slurm jobs.""" + from nvflow.lib.rl.helpers import resolve_environments - input_path = config["input_path"] - output_dir = config["output_dir"] + environments = resolve_environments(config) + base_input_dir = config["input_dir"] + base_output_dir = config["output_dir"] container = config["container"] - output_file = f"{output_dir}/final_result.jsonl" - console.status("Converting data to NeMo-Gym Responses API format") - console.detail("Input", input_path) - console.detail("Output", output_file) - console.blank() + for env_name, env_cfg in environments.items(): + if not env_cfg.get("raw_train_data"): + console.warning(f"Skipping environment '{env_name}': no raw_train_data configured") + continue + env_input_path = f"{base_input_dir}/{env_name}" + env_output_dir = f"{base_output_dir}/{env_name}" + env_output_file = f"{env_output_dir}/final_result.jsonl" + + console.status(f"Converting data for environment: {env_name}") + console.detail("Input", env_input_path) + console.detail("Output", env_output_file) + console.blank() + + self._submit_job( + input_path=env_input_path, + output_file=env_output_file, + output_dir=env_output_dir, + container=container, + cluster=cluster, + expname=f"{expname}-{env_name}", + run_after=run_after, + ) + + def _submit_job( + self, + *, + input_path: str, + output_file: str, + output_dir: str, + container: str, + cluster: str, + expname: str, + run_after: list[str] | None, + ) -> None: + from nemo_skills.pipeline.cli import run_cmd, wrap_arguments cmd = ( f"python -m nvflow.recipes.finance.utils.rl.responses_api_converter " @@ -66,6 +101,8 @@ def execute( def validate_config(self, config: dict[str, Any]) -> None: """Check that all required fields are present.""" - for field in ("input_path", "output_dir", "container"): + for field in ("input_dir", "output_dir", "container"): if not config.get(field): raise ValueError(f"'{field}' is required in convert_to_responses_api config") + if not config.get("environments"): + raise ValueError("'environments' is required in convert_to_responses_api config") diff --git a/nvflow/recipes/finance/stages/rl/prefetch_cache.py b/nvflow/recipes/finance/stages/rl/prefetch_cache.py new file mode 100644 index 0000000..108ffc3 --- /dev/null +++ b/nvflow/recipes/finance/stages/rl/prefetch_cache.py @@ -0,0 +1,111 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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-fetch SEC filing metadata cache (finance recipe). + +Optional CPU-only stage that populates the SEC filing metadata cache +before rollout collection. This avoids SEC.gov API calls during +GPU-intensive rollout jobs and eliminates race conditions when multiple +seeds share the same cache directory. + +Runs per-environment: only environments whose config includes a +``prefetch`` block are processed; others are silently skipped. +""" + +from typing import Any + +from nvflow.core import BaseStage, StageRegistry, console + + +@StageRegistry.register(recipe="finance", workflow="grpo", stage="prefetch_cache") +class PrefetchCacheStage(BaseStage): + """Pre-fetch environment-specific caches before rollout collection. + + Iterates over environments and submits a CPU-only Slurm job for each + one that has a ``prefetch`` block in its environment config. + """ + + def execute( + self, + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None = None, + ) -> None: + from nemo_skills.pipeline.cli import run_cmd, wrap_arguments + + from nvflow.lib.rl.helpers import resolve_environments + + environments = resolve_environments(config) + gym_path = config["gym_path"] + container = config["container"] + installation_command = config.get("installation_command") + + submitted = 0 + for env_name, env_cfg in environments.items(): + prefetch = env_cfg.get("prefetch") + if not prefetch: + continue + + script = prefetch["script"] + cache_dir = prefetch["cache_dir"] + ticker_config = prefetch["ticker_config"] + force = prefetch.get("force", False) + + cmd = f"cd {gym_path} && python {script} --cache_dir {cache_dir} --ticker_config {ticker_config}" + if force: + cmd += " --force" + + console.status(f"Prefetching cache for environment: {env_name}") + console.detail("Script", script) + console.detail("Cache dir", cache_dir) + console.detail("Ticker config", ticker_config) + console.detail("Force", str(force)) + console.blank() + + run_cmd( + ctx=wrap_arguments(cmd), + cluster=cluster, + container=container, + num_gpus=config.get("num_gpus", 0), + log_dir=f"{cache_dir}/logs", + expname=f"{expname}-{env_name}", + run_after=run_after, + installation_command=installation_command, + ) + submitted += 1 + + if submitted: + console.success(f"Submitted {submitted} prefetch job(s)") + else: + console.success("No environments require prefetch -- skipping") + + def validate_config(self, config: dict[str, Any]) -> None: + for field in ("gym_path", "container"): + if not config.get(field): + raise ValueError(f"'{field}' is required in prefetch_cache config") + + if not config.get("environments"): + raise ValueError("'environments' dict is required in prefetch_cache config") + + environments = config["environments"] + for env_name, env_cfg in environments.items(): + prefetch = env_cfg.get("prefetch") + if not prefetch: + continue + for key in ("script", "cache_dir", "ticker_config"): + if not prefetch.get(key): + raise ValueError( + f"'{key}' is required in environments.{env_name}.prefetch config" + ) diff --git a/nvflow/recipes/finance/stages/rl/prepare_data.py b/nvflow/recipes/finance/stages/rl/prepare_data.py index b1e6de9..4f50f49 100644 --- a/nvflow/recipes/finance/stages/rl/prepare_data.py +++ b/nvflow/recipes/finance/stages/rl/prepare_data.py @@ -17,51 +17,110 @@ ng_prepare_data stamps each JSONL record with an ``agent_ref`` field that tells NeMo-Gym which agent server to route the example to during training. -This stage generates an agent-config overlay YAML from the ``agents`` list -in the workflow YAML, writes it to ``{output_dir}/agent_config_overlay.yaml``, -and passes it as the last entry in ``+config_paths``. Supports multiple -agents (one per NeMo-Gym environment). +This stage derives agent definitions from the top-level ``environments`` +dict, generates an agent-config overlay YAML inside the Slurm job at +``{output_dir}/agent_config_overlay.yaml``, and passes it as the last +entry in ``+config_paths``. Supports multiple environments/agents. """ +import base64 from typing import Any import yaml from nvflow.core import BaseStage, StageRegistry, console -from nvflow.lib.rl.helpers import resolve_host_path @StageRegistry.register(recipe="finance", workflow="grpo", stage="prepare_data") class PrepareDataForGRPOStage(BaseStage): """Run ng_prepare_data to add agent_ref routing fields to JSONL data. - CPU-only stage (no GPU needed). Supports one or more agents (each - mapping to a NeMo-Gym environment). + CPU-only stage (no GPU needed). Derives agent definitions from the + top-level ``environments`` dict and supports multiple environments. Execution flow: - 1. Build an agent-config overlay from the ``agents`` list in the - workflow YAML and write it to {output_dir}/agent_config_overlay.yaml. - 2. Submit a Slurm job that runs ``ng_prepare_data`` with the overlay - appended to ``+config_paths``. + 1. Derive agent definitions from ``environments``. + 2. Build a shell snippet that writes the agent-config overlay to + {output_dir}/agent_config_overlay.yaml at job runtime. + 3. Submit a Slurm job that first writes the overlay, then runs + ``ng_prepare_data`` with the overlay appended to ``+config_paths``. """ # -- helpers -------------------------------------------------------------- @staticmethod - def _build_overlay(agents: list[dict[str, Any]]) -> dict: - """Build a NeMo-Gym agent-config overlay from the agents list. + def _agents_from_environments(environments: dict[str, Any]) -> list[dict[str, Any]]: + """Derive the agents list from the ``environments`` dict. - Each agent produces one top-level key in the overlay, keyed by its - ``name``. The ``agent_type`` field (default: ``simple_agent``) - controls the NeMo-Gym agent class used. + ``env_cfg["datasets"]`` is a dict keyed by NeMo-Gym DatasetType: + - ``"train"`` — required. + - ``"val"`` / ``"validation"`` — optional. Omitted in the current + GRPO pipeline because the post-rollout ``train_validation_split`` + stage produces the final val set (see base.yaml prepare_data + docstring). ng_prepare_data gracefully skips absent types + (see Gym/nemo_gym/train_data_utils.py:in_scope_dataset_types + + collate_samples). """ + agents = [] + for env_name, env_cfg in environments.items(): + env_datasets = env_cfg.get("datasets") or {} + dataset_entries: list[dict[str, Any]] = [] + if "train" in env_datasets: + dataset_entries.append( + { + "name": "train", + "type": "train", + "license": "TBD", + "jsonl_fpath": env_datasets["train"], + } + ) + # Accept either "val" or "validation" as the key for backward compat. + val_fpath = env_datasets.get("val") or env_datasets.get("validation") + if val_fpath: + dataset_entries.append( + { + "name": "validation", + "type": "validation", + "license": "TBD", + "jsonl_fpath": val_fpath, + } + ) + agents.append( + { + "name": env_cfg["agent_name"], + "agent_type": env_cfg.get("agent_type", "simple_agent"), + "entrypoint": "app.py", + "resources_server": { + "type": "resources_servers", + "name": env_cfg.get("resources_server_name", env_name), + }, + "model_server": {"type": "responses_api_models", "name": "policy_model"}, + "datasets": dataset_entries, + } + ) + return agents + + @staticmethod + def _config_paths_from_environments(environments: dict[str, Any]) -> list[str]: + """Collect environment config_paths. + + No model config is needed here -- Gym's NO_MODEL_GLOBAL_CONFIG_DICT + provides a dummy policy_model for data-only operations like + ng_prepare_data.""" + config_paths: list[str] = [] + for env_cfg in environments.values(): + config_paths.extend(env_cfg.get("config_paths", [])) + return config_paths + + @staticmethod + def _build_overlay(agents: list[dict[str, Any]]) -> dict: + """Build a NeMo-Gym agent-config overlay from the agents list.""" overlay: dict = {} for agent_cfg in agents: - agent_type = agent_cfg.get("agent_type", "simple_agent") overlay[agent_cfg["name"]] = { "responses_api_agents": { - agent_type: { - "entrypoint": agent_cfg.get("entrypoint", "app.py"), + agent_cfg["agent_type"]: { + "entrypoint": agent_cfg["entrypoint"], "resources_server": agent_cfg["resources_server"], "model_server": agent_cfg["model_server"], "datasets": agent_cfg["datasets"], @@ -70,22 +129,20 @@ def _build_overlay(agents: list[dict[str, Any]]) -> dict: } return overlay - def _write_overlay(self, output_dir: str, agents: list[dict[str, Any]]) -> str: - """Write the overlay YAML and return its **container** path.""" + def _overlay_shell_snippet( + self, output_dir: str, agents: list[dict[str, Any]] + ) -> tuple[str, str]: + """Return a shell snippet that writes the overlay YAML at job runtime.""" overlay = self._build_overlay(agents) - host_dir = resolve_host_path(output_dir) - host_dir.mkdir(parents=True, exist_ok=True) - overlay_path = host_dir / "agent_config_overlay.yaml" - agent_names = [a["name"] for a in agents] header = f"# Auto-generated by PrepareDataForGRPOStage.\n# Agents: {agent_names}\n" - overlay_path.write_text( - header + yaml.dump(overlay, default_flow_style=False, sort_keys=False) - ) - console.detail("Overlay written to", str(overlay_path)) + content = header + yaml.dump(overlay, default_flow_style=False, sort_keys=False) - return f"{output_dir}/agent_config_overlay.yaml" + encoded = base64.b64encode(content.encode()).decode() + overlay_path = f"{output_dir}/agent_config_overlay.yaml" + snippet = f"mkdir -p {output_dir} && echo {encoded} | base64 -d > {overlay_path}" + return snippet, overlay_path # -- main entry points ---------------------------------------------------- @@ -96,31 +153,73 @@ def execute( expname: str, run_after: list[str] | None = None, ) -> None: - """Submit the ng_prepare_data Slurm job.""" + """Submit per-environment ng_prepare_data Slurm jobs.""" + from nvflow.lib.rl.helpers import resolve_environments + + environments = resolve_environments(config) + base_output_dir = config["output_dir"] + # input_dir points at the upstream stage that produced the per-env + # file to be prepared (typically convert_to_responses_api). Only a + # single "train" dataset is fed to ng_prepare_data -- the post-rollout + # train_validation_split stage produces the final val set after + # collect_rollouts / compute_rewards, so we don't pre-split here. + # ng_prepare_data tolerates a single-dataset agent config (see + # Gym/nemo_gym/train_data_utils.py::in_scope_dataset_types and + # collate_samples -- absent types are silently skipped). + input_dir = config["input_dir"] + input_filename = config.get("input_filename", "final_result.jsonl") + + for env_name, env_cfg in environments.items(): + if not env_cfg.get("raw_train_data"): + console.warning(f"Skipping environment '{env_name}': no raw_train_data configured") + continue + env_output_dir = f"{base_output_dir}/{env_name}" + env_datasets = { + "train": f"{input_dir}/{env_name}/{input_filename}", + } + single_env = {env_name: {**env_cfg, "datasets": env_datasets}} + + self._submit_prepare_job( + single_env=single_env, + env_name=env_name, + env_output_dir=env_output_dir, + config=config, + cluster=cluster, + expname=f"{expname}-{env_name}", + run_after=run_after, + ) + + def _submit_prepare_job( + self, + *, + single_env: dict[str, Any], + env_name: str, + env_output_dir: str, + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None, + ) -> None: from nemo_skills.pipeline.cli import run_cmd, wrap_arguments - output_dir = config["output_dir"] gym_path = config["gym_path"] container = config["container"] installation_command = config.get("installation_command") mode = config.get("mode", "train_preparation") should_download = config.get("should_download", False) - agents = config["agents"] - # 1. Generate the overlay and append it to config_paths. - config_paths = list(config["nemo_gym_config_paths"]) - overlay_path = self._write_overlay(output_dir, agents) + agents = self._agents_from_environments(single_env) + config_paths = self._config_paths_from_environments(single_env) + + overlay_snippet, overlay_path = self._overlay_shell_snippet(env_output_dir, agents) config_paths.append(overlay_path) - # 2. Build the ng_prepare_data command. - # cd into the Gym directory so relative config paths resolve correctly. - # +error_on_almost_servers=false works around a gitlab_identifier - # validation bug in our local Gym version (fixed upstream). config_paths_str = ",".join(config_paths) cmd = ( + f"{overlay_snippet} && " f"cd {gym_path} && " f'ng_prepare_data "+config_paths=[{config_paths_str}]" ' - f"+output_dirpath={output_dir} " + f"+output_dirpath={env_output_dir} " f"+mode={mode} " f"+error_on_almost_servers=false" ) @@ -130,52 +229,62 @@ def execute( if extra_args: cmd += f" {extra_args}" - # 3. Display summary and submit. - console.status("Preparing data for GRPO training (ng_prepare_data)") + # Deterministic post-shuffle of train.jsonl. ng_prepare_data preserves + # SDG's per-filing clustering, which means collect_rollouts' contiguous + # chunking (num_chunks > 1) and ``head -n max_num_samples`` truncation + # see unbalanced question_type / date / company mixes. Shuffling here + # restores what the old pre-rollout train_validation_split used to + # provide implicitly. Seed-based + in-place; rerun-safe. + shuffle = config.get("shuffle", True) + random_seed = config.get("random_seed", 42) + if shuffle: + cmd += ( + f" && python -m nvflow.recipes.finance.utils.rl.shuffle_jsonl" + f" --input_file {env_output_dir}/train.jsonl" + f" --random_seed {random_seed}" + ) + + console.status(f"Preparing data for environment: {env_name}") console.detail("Mode", mode) for agent in agents: all_datasets = ", ".join(d["name"] for d in agent["datasets"]) - console.detail(f"Agent [{agent.get('agent_type', 'simple_agent')}]", agent["name"]) + console.detail(f"Agent [{agent['agent_type']}]", agent["name"]) console.detail(" Datasets", all_datasets) - console.detail("Output", output_dir) - console.detail("Command", cmd) + console.detail("Output", env_output_dir) + console.detail("Post-shuffle", f"seed={random_seed}" if shuffle else "disabled") console.blank() run_cmd( ctx=wrap_arguments(cmd), cluster=cluster, container=container, - num_gpus=0, - log_dir=f"{output_dir}/logs", + num_gpus=config.get("num_gpus", 0), + log_dir=f"{env_output_dir}/logs", expname=expname, run_after=run_after, installation_command=installation_command, ) - console.success(f"Data preparation job submitted → {output_dir}/") + console.success(f"Data preparation job submitted → {env_output_dir}/") def validate_config(self, config: dict[str, Any]) -> None: """Check that all required fields are present.""" - for field in ("output_dir", "gym_path", "container", "nemo_gym_config_paths"): + for field in ("output_dir", "gym_path", "container", "input_dir"): if not config.get(field): raise ValueError(f"'{field}' is required in prepare_data config") - agents = config.get("agents") - if not isinstance(agents, list) or not agents: - raise ValueError("'agents' must be a non-empty list in prepare_data config") - - for idx, agent in enumerate(agents): - prefix = f"agents[{idx}]" - for field in ("name", "resources_server", "model_server", "datasets"): - if field not in agent: - raise ValueError(f"'{prefix}.{field}' is required") - - if not isinstance(agent.get("datasets"), list) or not agent["datasets"]: - raise ValueError(f"'{prefix}.datasets' must be a non-empty list") + environments = config.get("environments") + if not isinstance(environments, dict) or not environments: + raise ValueError("'environments' must be a non-empty dict in prepare_data config") - for i, ds in enumerate(agent["datasets"]): - for field in ("name", "type", "jsonl_fpath"): - if field not in ds: - raise ValueError(f"'{prefix}.datasets[{i}].{field}' is required") + for env_name, env_cfg in environments.items(): + if not env_cfg.get("config_paths"): + raise ValueError(f"environments.{env_name}.config_paths is required") + if not env_cfg.get("agent_name"): + raise ValueError( + f"environments.{env_name}.agent_name is required. " + f"Set it to the agent Server ID from the Gym config YAML " + f"(the top-level key above 'responses_api_agents')." + ) mode = config.get("mode", "train_preparation") if mode not in ("train_preparation", "example_validation"): diff --git a/nvflow/recipes/finance/stages/rl/training.py b/nvflow/recipes/finance/stages/rl/training.py index 44f4ce1..2f75001 100644 --- a/nvflow/recipes/finance/stages/rl/training.py +++ b/nvflow/recipes/finance/stages/rl/training.py @@ -14,20 +14,20 @@ # """GRPO Reinforcement Learning Training for financial reasoning models. -Uses NeMo-RL + NeMo-Gym via the nemo-skills grpo_nemo_rl() orchestrator. -The NeMo-Gym entry point swap and dependencies are configured in the workflow -YAML (installation_command) for full transparency. - -The full config (preset + overrides) is written as a YAML file and passed -to run_grpo_nemo_gym.py via ``--config``. nemo-skills' runtime overrides -(model_name, cluster, checkpoint_dir, etc.) are applied on top via -``++key=value`` CLI args. This "pass everything from scratch" approach -matches SFT and avoids any dependency on upstream default config files. +Submits GRPO training via direct ``add_task()`` calls to nemo-run, +bypassing ``nemo-skills`` ``grpo_nemo_rl()`` to avoid unwanted data-key +injections and the ``cp`` entry-point hack. + +The full config (preset + overrides) is base64-encoded and decoded +inside the Slurm job, then passed to ``run_grpo_nemo_gym.py`` via +``--config``. Runtime overrides (model_name, cluster, checkpoint_dir, +data paths, etc.) are applied on top via ``++key=value`` CLI args. """ -import os +import base64 +import subprocess +import warnings from dataclasses import dataclass -from datetime import datetime from pathlib import Path from typing import Any @@ -35,8 +35,10 @@ from omegaconf import OmegaConf from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.gpu_layout import resolve_gpu_layout from nvflow.lib.rl.helpers import ( - JUDGE_NON_VLLM_KEYS, + NON_VLLM_KEYS, + VLLM_MODEL_FOR_TRAINING, build_judge_nemo_gym_config, build_vllm_server_args, determine_judge_mode, @@ -44,6 +46,7 @@ resolve_host_path, validate_judge_config, ) +from nvflow.lib.vllm_compat import get_server_entrypoint @dataclass @@ -68,19 +71,8 @@ class GRPOStage(BaseStage): """GRPO reinforcement learning training for financial reasoning. Merges a preset (grpo_presets.yaml) with workflow overrides, validates - parallelism, and submits via nemo-skills grpo_nemo_rl(). - - Example workflow config:: - - training: - preset: "grpo-base" - backend: fsdp - overrides: - grpo: - num_prompts_per_step: 64 - policy: - dtensor_cfg: - tensor_parallel_size: 2 + parallelism, builds ``env.nemo_gym.config_paths`` from the + ``environments`` dict, and submits via direct ``add_task()`` calls. """ def __init__(self): @@ -130,7 +122,19 @@ def _get_parallelism_config(self, policy: dict, backend: str) -> dict[str, int]: } def _resolve_nemo_rl_config(self, config: dict) -> dict: - """Resolve NeMo-RL format preset with overrides.""" + """Resolve NeMo-RL format preset with overrides. + + Dynamically builds ``env.nemo_gym.config_paths`` from the + ``environments`` dict. When ``training_datasets`` is present + (multi-environment combined training), injects ``data.train`` + and ``data.validation`` as lists so NeMo-RL uses its native + multi-dataset support instead of a single merged file. + + For single-environment training, merges the environment's + ``training_policy`` onto the resolved policy config (e.g. to + override context length). Combined training uses the + model-level default and ignores per-environment overrides. + """ preset_name = config.get("preset") if not preset_name or preset_name not in self.presets: raise ValueError( @@ -143,6 +147,30 @@ def _resolve_nemo_rl_config(self, config: dict) -> dict: overrides = OmegaConf.create(config.get("overrides", {})) merged = OmegaConf.to_container(OmegaConf.merge(preset, overrides)) + environments = config["environments"] + config_paths = [VLLM_MODEL_FOR_TRAINING] + for env_cfg in environments.values(): + config_paths.extend(env_cfg.get("config_paths", [])) + merged.setdefault("env", {}).setdefault("nemo_gym", {})["config_paths"] = config_paths + + if config.get("training_datasets"): + merged["data"]["train"] = config["training_datasets"] + if config.get("validation_datasets"): + merged["data"]["validation"] = config["validation_datasets"] + + if len(environments) == 1: + env_name = next(iter(environments)) + env_cfg = environments[env_name] + tp = env_cfg.get("training_policy") + if tp: + merged["policy"] = OmegaConf.to_container( + OmegaConf.merge( + OmegaConf.create(merged.get("policy", {})), + OmegaConf.create(tp), + ) + ) + console.detail("Training policy", f"Applied overrides from {env_name}") + return merged def _auto_correct_sequence_parallel(self, nemo_rl_config: dict, backend: str) -> None: @@ -215,8 +243,6 @@ def _validate_parallelism_config( expert_dp = world_size // expert_model_size if regular_dp != expert_dp: - import warnings - warnings.warn( f"MoE DP mismatch detected: Regular DP={regular_dp}, Expert DP={expert_dp}. " f"This may cause distributed optimizer gradient buffer allocation issues. " @@ -274,35 +300,97 @@ def _validate_sequence_packing_for_cp(self, nemo_rl_config: dict, backend: str) f" train_mb_tokens: {policy.get('max_total_sequence_length', 4096)}" ) + def _auto_correct_sequence_length_divisibility( + self, nemo_rl_config: dict, backend: str + ) -> None: + """Auto-compute policy.make_sequence_length_divisible_by from parallelism. + + Megatron splits individual sequences across CP and TP (when SP=true) + ranks, requiring sequence lengths to be divisible by a minimum factor: + - CP > 1 contributes cp_size * 2 (send/receive pattern) + - TP > 1 + SP=true contributes tp_size + + If the user explicitly set a higher value (e.g., for FP8 alignment), + it is preserved. Mirrors the SFT stage's identical method. + """ + policy = nemo_rl_config.get("policy", {}) + parallel = self._get_parallelism_config(policy, backend) + + if backend == "fsdp": + cfg = policy.get("dtensor_cfg", {}) + else: + cfg = policy.get("megatron_cfg", {}) + + tp = parallel["tp"] + cp = parallel["cp"] + sp = cfg.get("sequence_parallel", False) + + minimum = 1 + if cp > 1: + minimum *= cp * 2 + if tp > 1 and sp: + minimum *= tp + + current = policy.get("make_sequence_length_divisible_by", 1) + corrected = max(current, minimum) + + if corrected != current: + console.detail( + "Auto-corrected make_sequence_length_divisible_by", + f"{current} → {corrected} (CP={cp}, TP={tp}, SP={sp})", + ) + policy["make_sequence_length_divisible_by"] = corrected + def _inject_judge_config( self, config: dict[str, Any], nemo_rl_config: dict, output_dir: str, - cluster: str, + cluster_config: dict, ) -> tuple[str, dict | None]: """Inject dedicated judge model config into NeMo-Gym and optionally build a judge job info dict for local_vllm mode. + Judge configuration is read from the per-environment ``judge_vllm`` + block. For single-environment training, the judge comes from that + environment. For combined training, the first environment with a + non-trivial judge (``num_gpus > 0`` or ``model_path`` set) is used. + Returns: (judge_mode, judge_job_info) where judge_job_info is set only in local_vllm mode. """ - judge_mode = determine_judge_mode(config) + environments = config["environments"] + + judge_env_name = None + judge_env_cfg: dict[str, Any] = {} + judge_vllm_cfg: dict[str, Any] = {} + for name, ecfg in environments.items(): + jv = ecfg.get("judge_vllm") or {} + if jv.get("model_path") or jv.get("base_url") or jv.get("openai_base_url"): + judge_env_name = name + judge_env_cfg = ecfg + judge_vllm_cfg = jv + break + + if not judge_env_name: + judge_env_name = next(iter(environments)) + judge_env_cfg = environments[judge_env_name] + + rs_name = judge_env_cfg.get("resources_server_name", judge_env_name) + + config_with_judge = {**config, "judge_vllm": judge_vllm_cfg} + judge_mode = determine_judge_mode(config_with_judge) if judge_mode != "policy_as_judge": - validate_judge_config(config) + validate_judge_config(config_with_judge) - config_paths = nemo_rl_config.get("env", {}).get("nemo_gym", {}).get("config_paths", []) - env_name = next( - (Path(p).stem for p in config_paths if "resources_servers" in p), - "equivalence_llm_judge", - ) nemo_gym_cfg = nemo_rl_config.setdefault("env", {}).setdefault("nemo_gym", {}) judge_cfg_fragment = build_judge_nemo_gym_config( - config, + config_with_judge, judge_mode, - environment_name=env_name, + environment_name=rs_name, + environment_inner_name=judge_env_name, ) if judge_cfg_fragment: @@ -316,19 +404,20 @@ def _inject_judge_config( judge_job_info = None if judge_mode == "local_vllm": - jcfg = config.get("judge_vllm") or {} - vllm_overrides = {k: v for k, v in jcfg.items() if k not in JUDGE_NON_VLLM_KEYS} + vllm_overrides = {k: v for k, v in judge_vllm_cfg.items() if k not in NON_VLLM_KEYS} from nemo_skills.pipeline.utils.server import get_free_port judge_port = get_free_port(strategy="random") - num_gpus = jcfg.get("num_gpus", 4) - num_nodes = jcfg.get("server_nodes", 1) + num_gpus = judge_vllm_cfg.get("num_gpus", 4) + num_nodes = judge_vllm_cfg.get("server_nodes", 1) server_args = build_vllm_server_args(vllm_overrides) host_file = f"{output_dir}/judge_host.txt" + ep = get_server_entrypoint() # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) + serve_cmd = f"python3 {ep}" vllm_cmd = ( - f"python3 -m nemo_skills.inference.server.serve_vllm" - f" --model {jcfg['model_path']}" + f"{serve_cmd}" + f" --model {judge_vllm_cfg['model_path']}" f" --num_gpus {num_gpus}" f" --num_nodes {num_nodes}" f" --port {judge_port}" @@ -341,9 +430,6 @@ def _inject_judge_config( f"{vllm_cmd}" ) - from nemo_skills.pipeline.utils.cluster import get_cluster_config - - cluster_config = get_cluster_config(cluster) judge_job_info = { "server_cmd": wrapped_cmd, "port": judge_port, @@ -365,15 +451,28 @@ def _prepare_grpo_config( cluster: str, expname: str, run_after: list[str] | None = None, + cluster_config: dict | None = None, ) -> PreparedGRPOConfig: """Merge preset + overrides, validate, and build PreparedGRPOConfig.""" + if cluster_config is None: + from nemo_skills.pipeline.utils.cluster import get_cluster_config + + cluster_config = get_cluster_config(cluster) + hf_model_name = config["model_name"] - num_nodes = config.get("num_nodes", 1) - num_gpus = config.get("num_gpus", 8) backend = config.get("backend", "fsdp") + # Resolve GPU layout: total_gpus (portable) or legacy num_nodes+num_gpus + layout = resolve_gpu_layout(config, cluster_config) + num_nodes = layout.num_nodes + num_gpus = layout.gpus_per_node + console.detail( + "GPU layout", f"{num_nodes} node(s) x {num_gpus} GPUs = {layout.total_gpus} total" + ) + nemo_rl_config = self._resolve_nemo_rl_config(config) self._auto_correct_sequence_parallel(nemo_rl_config, backend) + self._auto_correct_sequence_length_divisibility(nemo_rl_config, backend) self._validate_parallelism_config(nemo_rl_config, backend, num_nodes, num_gpus) self._validate_sequence_packing_for_cp(nemo_rl_config, backend) @@ -381,13 +480,11 @@ def _prepare_grpo_config( parallel = self._get_parallelism_config(policy, backend) seq_k = policy.get("max_total_sequence_length", 32768) // 1024 model_short = Path(hf_model_name).name.lower().replace("_", "-") - run_name = ( - f"grpo-{model_short}-{num_nodes}n-tp{parallel['tp']}-cp{parallel['cp']}-seq{seq_k}k" - ) + run_name = f"grpo-{model_short}-{layout.total_gpus}g-tp{parallel['tp']}-cp{parallel['cp']}-seq{seq_k}k" output_dir = str(Path(config["output_dir"]) / run_name) judge_mode, judge_job_info = self._inject_judge_config( - config, nemo_rl_config, output_dir, cluster + config, nemo_rl_config, output_dir, cluster_config ) return PreparedGRPOConfig( @@ -412,10 +509,17 @@ def _display_grpo_summary(self, prepared: PreparedGRPOConfig, config: dict[str, console.status("Preparing GRPO training job (NeMo-RL + NeMo-Gym)") console.detail("Model", prepared.hf_model_name) - if config.get("training_data"): - console.detail("Training data", config["training_data"]) - if config.get("validation_data"): - console.detail("Validation data", config["validation_data"]) + if config.get("training_datasets"): + datasets = config["training_datasets"] + console.detail("Training data", f"{len(datasets)} datasets (multi-environment)") + for ds in datasets: + repeat_str = f" (repeat={ds['repeat']})" if ds.get("repeat", 1) > 1 else "" + console.detail(" Dataset", f"{ds['data_path']}{repeat_str}") + val_datasets = config.get("validation_datasets", []) + console.detail("Validation data", f"{len(val_datasets)} datasets") + else: + console.detail("Training data", config.get("training_data", "(from config)")) + console.detail("Validation data", config.get("validation_data", "(from config)")) console.detail("Cluster", f"{prepared.num_nodes}×{prepared.num_gpus} = {world_size} GPUs") if prepared.backend == "fsdp": @@ -432,11 +536,7 @@ def _display_grpo_summary(self, prepared: PreparedGRPOConfig, config: dict[str, f"generations/prompt={grpo.get('num_generations_per_prompt', '?')}", ) - env = prepared.nemo_rl_config.get("env", {}) - config_paths = env.get("nemo_gym", {}).get("config_paths", []) - env_names = [Path(p).stem for p in config_paths if "resources_servers" in p] - if env_names: - console.detail("NeMo-Gym environment", ", ".join(env_names)) + console.detail("NeMo-Gym environments", ", ".join(config["environments"].keys())) log_judge_details(console, config, prepared.judge_mode) if prepared.judge_job_info: @@ -446,242 +546,342 @@ def _display_grpo_summary(self, prepared: PreparedGRPOConfig, config: dict[str, ) console.blank() - def _write_config_yaml(self, prepared: PreparedGRPOConfig) -> str: - """Write the full NeMo-RL config as a YAML file and return its container path.""" - output_path = resolve_host_path(prepared.output_dir) - output_path.mkdir(parents=True, exist_ok=True) - config_file = output_path / "grpo_config.yaml" - - with open(config_file, "w") as f: - f.write("# Auto-generated GRPO config (preset + overrides)\n") - f.write("# Passed to run_grpo_nemo_gym.py via --config\n\n") - yaml.dump( - prepared.nemo_rl_config, - f, - default_flow_style=False, - sort_keys=False, - allow_unicode=True, - ) + def _config_shell_snippet(self, prepared: PreparedGRPOConfig) -> tuple[str, str]: + """Return a shell snippet that writes the NeMo-RL config YAML at job runtime. - console.detail("Config YAML written to", str(config_file)) - return f"{prepared.output_dir}/grpo_config.yaml" + The config is base64-encoded and decoded inside the Slurm job, + avoiding any host-side filesystem writes. Same pattern used by + ``PrepareDataForGRPOStage._overlay_shell_snippet``. + """ + content = yaml.dump( + prepared.nemo_rl_config, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + encoded = base64.b64encode(content.encode()).decode() + config_path = f"{prepared.output_dir}/grpo_config.yaml" + snippet = f"mkdir -p {prepared.output_dir} && echo {encoded} | base64 -d > {config_path}" + return snippet, config_path - def _submit_grpo_job( - self, prepared: PreparedGRPOConfig, cluster: str, config: dict[str, Any] - ) -> None: - """Submit GRPO training job via nemo-skills grpo_nemo_rl().""" - from nemo_skills.pipeline.cli import grpo_nemo_rl, wrap_arguments + def _build_train_cmd( + self, + prepared: PreparedGRPOConfig, + config: dict[str, Any], + config_snippet: str, + config_path: str, + cluster_config: dict, + ) -> str: + """Build the training command string for run_grpo_nemo_gym.py.""" + from nemo_skills.pipeline.nemo_rl.grpo import get_timeout_str + + stage_kwargs = config.get("stage_kwargs", {}) + partition = stage_kwargs.get("partition") + timeout = config.get("overrides", {}).get("checkpointing", {}).get( + "checkpoint_must_save_by" + ) or get_timeout_str(cluster_config, partition) + hf_model = config.get("hf_checkpoint_path", config["model_name"]) + + cmd = ( + f"{config_snippet} && " + f"export PYTHONPATH=$PYTHONPATH:/nemo_run/code:/opt/NeMo-RL && " + f"export UV_PROJECT=/opt/NeMo-RL && " + f"echo 'Starting training' && " + f"uv run --active python /opt/NeMo-RL/examples/nemo_gym/run_grpo_nemo_gym.py " + f" --config {config_path}" + f" ++policy.model_name={hf_model}" + f" ++cluster.gpus_per_node={prepared.num_gpus}" + f" ++cluster.num_nodes={prepared.num_nodes}" + f" ++checkpointing.checkpoint_must_save_by={timeout}" + f" ++logger.log_dir={prepared.output_dir}/training-logs" + f" ++checkpointing.checkpoint_dir={prepared.output_dir}/checkpoints" + ) - self._save_grpo_metadata(prepared, config) - config_path = self._write_config_yaml(prepared) + if prepared.backend == "megatron": + cmd += " ++policy.dtensor_cfg.enabled=false ++policy.megatron_cfg.enabled=true" + cmd += " ++policy.optimizer=None ++policy.dynamic_batching.enabled=false" + else: + cmd += " ++policy.dtensor_cfg.enabled=true ++policy.megatron_cfg.enabled=false" + if config.get("training_data"): + cmd += f" ++data.train.data_path={config['training_data']}" + if config.get("validation_data"): + cmd += f" ++data.validation.data_path={config['validation_data']}" wandb_mode = config.get("wandb_mode", "disabled") + if wandb_mode == "disabled": + cmd += " ++logger.wandb_enabled=false" + elif wandb_mode == "offline": + cmd += " ++logger.wandb_enabled=true ++logger.wandb_mode=offline" + elif wandb_mode == "online": + wandb_project = config.get("wandb_project", "finance-grpo") + cmd += ( + f" ++logger.wandb_enabled=true" + f" ++logger.wandb.project={wandb_project}" + f" ++logger.wandb.name={prepared.expname}" + f" ++logger.wandb.group={prepared.expname}" + ) + extra_parts = [] if base_args := config.get("extra_arguments"): extra_parts.append(base_args) - if stage_args := config.get("stage_kwargs", {}).get("extra_arguments"): + if stage_args := stage_kwargs.get("extra_arguments"): extra_parts.append(stage_args) - extra_arguments = " ".join(extra_parts) if extra_parts else None + if extra_parts: + cmd += " " + " ".join(extra_parts) - args = f"--config {config_path}" - if config.get("training_data"): - args = f"{args} ++data.train_jsonl_fpath={config['training_data']}" - if config.get("validation_data"): - args = f"{args} ++data.validation_jsonl_fpath={config['validation_data']}" - if wandb_mode == "disabled": - args = f"{args} ++logger.wandb_enabled=false" - elif wandb_mode == "offline": - args = f"{args} ++logger.wandb_enabled=true ++logger.wandb_mode=offline" - if extra_arguments: - args = f"{args} {extra_arguments}" - - grpo_kwargs: dict[str, Any] = { - "ctx": wrap_arguments(args), - "cluster": cluster, - "expname": prepared.expname, - "backend": prepared.backend, - "output_dir": prepared.output_dir, - "hf_model": config.get("hf_checkpoint_path", config["model_name"]), - "num_gpus": prepared.num_gpus, - "num_nodes": prepared.num_nodes, - "dependent_jobs": config.get("dependent_jobs", 0), - "installation_command": config.get("installation_command"), - } - - if prepared.run_after: - grpo_kwargs["run_after"] = prepared.run_after + return cmd + + def _submit_grpo_job( + self, prepared: PreparedGRPOConfig, cluster_config: dict, config: dict[str, Any] + ) -> None: + """Submit GRPO training job via direct add_task() + run_exp().""" + from nemo_skills.pipeline.nemo_rl.grpo import parse_kwargs + from nemo_skills.pipeline.utils.exp import add_task, get_exp, run_exp + + config_snippet, config_path = self._config_shell_snippet(prepared) + + train_cmd = self._build_train_cmd( + prepared, config, config_snippet, config_path, cluster_config + ) stage_kwargs = config.get("stage_kwargs", {}) - if "partition" in stage_kwargs: - grpo_kwargs["partition"] = stage_kwargs["partition"] - if wandb_mode == "online" and config.get("wandb_project"): - grpo_kwargs["wandb_project"] = config["wandb_project"] + partition = stage_kwargs.get("partition") + sbatch_kwargs = parse_kwargs(stage_kwargs.get("sbatch_kwargs", "")) + + dependent_jobs = config.get("dependent_jobs", 0) if prepared.judge_job_info is not None: - self._submit_judge_and_training(prepared, grpo_kwargs, cluster) + self._submit_judge_and_training( + prepared, + train_cmd, + cluster_config, + config, + partition, + sbatch_kwargs, + dependent_jobs, + ) else: - grpo_nemo_rl(**grpo_kwargs) + with get_exp(prepared.expname, cluster_config) as exp: + prev_task = None + for job_id in range(dependent_jobs + 1): + prev_task = add_task( + exp, + cmd=train_cmd, + task_name=f"{prepared.expname}-grpo-{job_id}", + log_dir=f"{prepared.output_dir}/training-logs", + container=cluster_config["containers"]["nemo-rl"], + num_gpus=prepared.num_gpus, + num_nodes=prepared.num_nodes, + cluster_config=cluster_config, + with_ray=True, + sbatch_kwargs=sbatch_kwargs, + installation_command=config.get("installation_command"), + partition=partition, + run_after=prepared.run_after, + task_dependencies=[prev_task] if prev_task else None, + ) + run_exp(exp, cluster_config, sequential=False) console.success("GRPO training job submitted") def _submit_judge_and_training( self, prepared: PreparedGRPOConfig, - grpo_kwargs: dict[str, Any], - cluster: str, + train_cmd: str, + cluster_config: dict, + config: dict[str, Any], + partition: str | None, + sbatch_kwargs: dict | None, + dependent_jobs: int = 0, ) -> None: - """Submit judge vLLM and training as two separate Slurm jobs.""" - from nemo_skills.pipeline.cli import grpo_nemo_rl, wrap_arguments - from nemo_skills.pipeline.utils.cluster import get_cluster_config - from nemo_skills.pipeline.utils.exp import add_task, get_exp + """Submit paired (judge + training) Slurm jobs. + + Each training job gets its own judge vLLM server so the judge + doesn't time out when ``dependent_jobs > 0``. For the default + case (``dependent_jobs = 0``), this produces one judge + one + training job, same as before. + + The training job is submitted first so the judge can use + ``--dependency=after:`` to avoid allocating + GPUs before training is actually running. A background + health-check inside the training command detects judge + failures and triggers graceful Ray shutdown via the ENDED + file mechanism. + """ + from nemo_skills.pipeline.utils.cluster import get_slurm_timeout_str + from nemo_skills.pipeline.utils.exp import add_task, get_exp, run_exp judge = prepared.judge_job_info - cluster_config = get_cluster_config(cluster) + base_host_file = judge["host_file"] + log_dir = f"{prepared.output_dir}/training-logs" + training_timeout = get_slurm_timeout_str(cluster_config, partition, with_save_delay=False) - # Poll for the judge hostname file (300 × 2s = 10 min timeout), - # then inject the URL as a CLI override for the training job. - host_file = judge["host_file"] - wait_and_cat = ( - f"n=0; while [ ! -f {host_file} ] && [ $n -lt 300 ]; do" - f" sleep 2; n=$((n+1)); done; cat {host_file}" - ) - judge_url_override = ( - "++env.nemo_gym.judge_model.responses_api_models.vllm_model.base_url=" - f"http://$({wait_and_cat})/v1" - ) + with get_exp(prepared.expname, cluster_config) as exp: + prev_train_task = None - original_args = " ".join(grpo_kwargs["ctx"].args) - grpo_kwargs["ctx"] = wrap_arguments(f"{original_args} {judge_url_override}") + for job_id in range(dependent_jobs + 1): + if dependent_jobs > 0: + host_file_i = base_host_file.replace(".txt", f"_{job_id}.txt") + else: + host_file_i = base_host_file - resolve_host_path(host_file).unlink(missing_ok=True) + raw_judge_cmd = judge["server_cmd"].replace(base_host_file, host_file_i) + judge_cmd_i = f"rm -f {host_file_i} && {raw_judge_cmd}" - with get_exp(prepared.expname, cluster_config) as exp: - add_task( - exp, - cmd=judge["server_cmd"], - task_name=f"{prepared.expname}-judge", - log_dir=f"{prepared.output_dir}/training-logs", - container=judge["container"], - num_gpus=judge["num_gpus"], - num_nodes=judge["num_nodes"], - cluster_config=cluster_config, - run_after=prepared.run_after, - ) - console.detail("Judge vLLM job", "submitted (separate Slurm job)") + wait_and_cat = ( + f"n=0; while [ ! -f {host_file_i} ] && [ $n -lt 300 ]; do" + f" sleep 2; n=$((n+1)); done; cat {host_file_i}" + ) + judge_url_override = ( + "++env.nemo_gym.judge_model.responses_api_models.vllm_model.base_url=" + f"http://$({wait_and_cat})/v1" + ) + + judge_health_check = ( + "{ _nvflow_jhc() { " + f"while [ ! -f {host_file_i} ]; do sleep 10; done; " + f'JH=$(cat {host_file_i} 2>/dev/null || echo ""); ' + '[ -z "$JH" ] && return; ' + 'echo "[nvflow] Judge host=$JH, waiting for /health..."; ' + 'while ! curl -sf "http://$JH/health" >/dev/null 2>&1; do sleep 15; done; ' + 'echo "[nvflow] Judge healthy, monitoring started"; ' + "F=0; " + "while true; do " + " sleep 60; " + ' if ! curl -sf "http://$JH/health" >/dev/null 2>&1; then ' + " F=$((F+1)); " + ' echo "[nvflow] Judge health check failed ($F/3)"; ' + " [ $F -ge 3 ] && { " + ' echo "[nvflow] Judge unreachable, triggering shutdown..."; ' + f" touch {log_dir}/ENDED; " + " return; }; " + " else F=0; fi; " + "done; " + "}; _nvflow_jhc & } && " + ) + + train_cmd_i = f"{judge_health_check}{train_cmd} {judge_url_override}" + + prev_train_task = add_task( + exp, + cmd=train_cmd_i, + task_name=f"{prepared.expname}-grpo-{job_id}", + log_dir=log_dir, + container=cluster_config["containers"]["nemo-rl"], + num_gpus=prepared.num_gpus, + num_nodes=prepared.num_nodes, + cluster_config=cluster_config, + with_ray=True, + sbatch_kwargs=sbatch_kwargs, + installation_command=config.get("installation_command"), + partition=partition, + run_after=prepared.run_after, + task_dependencies=[prev_train_task] if prev_train_task else None, + ) - grpo_kwargs["_reuse_exp"] = exp - grpo_nemo_rl(**grpo_kwargs) + judge_sbatch = {"dependency_type": "after", "time": training_timeout} + add_task( + exp, + cmd=judge_cmd_i, + task_name=f"{prepared.expname}-judge-{job_id}", + log_dir=log_dir, + container=judge["container"], + num_gpus=judge["num_gpus"], + num_nodes=judge["num_nodes"], + cluster_config=cluster_config, + task_dependencies=[prev_train_task], + sbatch_kwargs=judge_sbatch, + partition=partition, + ) - self._submit_judge_cleanup(exp, prepared, cluster_config) + num_pairs = dependent_jobs + 1 + console.detail( + "Judge + training jobs", + f"{num_pairs} pair(s) submitted", + ) + run_exp(exp, cluster_config, sequential=False) + self._submit_judge_cleanup(exp, prepared, cluster_config, num_pairs=num_pairs) def _submit_judge_cleanup( - self, exp, prepared: PreparedGRPOConfig, cluster_config: dict + self, + exp, + prepared: PreparedGRPOConfig, + cluster_config: dict, + num_pairs: int = 1, ) -> None: - """Submit a bare sbatch job that cancels the judge after training.""" - import subprocess + """Submit per-pair cleanup jobs that cancel each judge after its training job. + ``exp.jobs`` is ordered ``[grpo-0, judge-0, grpo-1, judge-1, ...]``. + For each pair *i*, a lightweight CPU job is submitted with + ``--dependency=afterany:`` that runs + ``scancel --name=``. + """ if not exp.jobs: return - last_handle = exp.jobs[-1].handle - if not last_handle: - return - - # Handle format: ":////master/0" - try: - _, _, path_str = last_handle.partition("://") - slurm_job_id = path_str.split("/")[1] - int(slurm_job_id) # validate it's numeric - except (ValueError, IndexError): - console.warning( - f"Could not extract Slurm job ID from handle '{last_handle}', " - "skipping judge cleanup job" - ) - return - prefix = cluster_config.get("job_name_prefix", "") - judge_job_name = f"{prefix}{prepared.expname}-judge" account = cluster_config.get("account", "") partition = cluster_config.get("cpu_partition") or cluster_config.get("partition", "batch") - log_file = f"{prepared.output_dir}/training-logs/judge-cleanup-%j.log" - - sbatch_script = ( - "#!/bin/bash\n" - f"#SBATCH --job-name={prefix}{prepared.expname}-judge-cleanup\n" - f"#SBATCH --account={account}\n" - f"#SBATCH --partition={partition}\n" - "#SBATCH --nodes=1\n" - "#SBATCH --ntasks=1\n" - "#SBATCH --time=00:05:00\n" - f"#SBATCH --output={log_file}\n" - f"#SBATCH --error={log_file}\n" - f"#SBATCH --dependency=afterany:{slurm_job_id}\n" - f"scancel --name={judge_job_name} --user=$USER 2>/dev/null || true\n" - ) - - try: - result = subprocess.run( - ["sbatch"], - input=sbatch_script, - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode == 0: - console.detail( - "Judge cleanup job", - f"submitted ({result.stdout.strip()})", - ) - else: - console.warning(f"Failed to submit judge cleanup job: {result.stderr.strip()}") - except Exception as e: - console.warning(f"Could not submit judge cleanup job: {e}") - - def _save_grpo_metadata(self, prepared: PreparedGRPOConfig, config: dict[str, Any]) -> None: - """Save run metadata YAML for reproducibility.""" - slurm_job_id = os.environ.get("SLURM_JOB_ID") - run_id = f"job_{slurm_job_id}" if slurm_job_id else datetime.now().strftime("%Y%m%d_%H%M%S") - - judge_info = prepared.judge_job_info - metadata = { - "start_time": datetime.now().isoformat(), - "slurm_job_id": slurm_job_id, - "status": "submitted", - "format": "nemo_rl_grpo", - "preset": config.get("preset"), - "backend": prepared.backend, - "run_name": prepared.run_name, - "output_dir": prepared.output_dir, - "hf_model_name": prepared.hf_model_name, - "num_nodes": prepared.num_nodes, - "num_gpus": prepared.num_gpus, - "installation_command": config.get("installation_command"), - "extra_arguments": config.get("extra_arguments"), - "judge_mode": prepared.judge_mode, - "judge_job_info": { - "num_gpus": judge_info["num_gpus"], - "port": judge_info["port"], - "host_file": judge_info["host_file"], - } - if judge_info - else None, - "nemo_rl_config": prepared.nemo_rl_config, - } - - output_path = resolve_host_path(prepared.output_dir) - try: - output_path.mkdir(parents=True, exist_ok=True) - metadata_file = output_path / f"run_metadata_{run_id}.yaml" - - with open(metadata_file, "w") as f: - f.write(f"# GRPO Run Metadata - {run_id}\n") - f.write("# Auto-generated for reproducibility (NeMo-RL + NeMo-Gym)\n\n") - yaml.dump( - metadata, f, default_flow_style=False, sort_keys=False, allow_unicode=True + host_log_dir = resolve_host_path(f"{prepared.output_dir}/training-logs") + log_file = f"{host_log_dir}/judge-cleanup-%j.log" + + for pair_idx in range(num_pairs): + train_job_idx = pair_idx * 2 # grpo-0 at 0, grpo-1 at 2, ... + + try: + handle = exp.jobs[train_job_idx].handle + except IndexError: + continue + if not handle: + continue + + # Handle format: ":////master/0" + try: + _, _, path_str = handle.partition("://") + slurm_job_id = path_str.split("/")[1] + int(slurm_job_id) + except (ValueError, IndexError): + console.warning( + f"Could not extract Slurm job ID from handle '{handle}', " + f"skipping cleanup for pair {pair_idx}" ) + continue + + judge_name = f"{prefix}{prepared.expname}-judge-{pair_idx}" + sbatch_script = ( + "#!/bin/bash\n" + f"#SBATCH --job-name={prefix}{prepared.expname}-judge-cleanup-{pair_idx}\n" + f"#SBATCH --account={account}\n" + f"#SBATCH --partition={partition}\n" + "#SBATCH --nodes=1\n" + "#SBATCH --ntasks=1\n" + "#SBATCH --gpus-per-node=0\n" + "#SBATCH --time=00:05:00\n" + f"#SBATCH --output={log_file}\n" + f"#SBATCH --error={log_file}\n" + f"#SBATCH --dependency=afterany:{slurm_job_id}\n" + f"scancel --name={judge_name} --user=$USER 2>/dev/null || true\n" + ) - console.detail("Run metadata saved", str(metadata_file)) - except (OSError, PermissionError) as e: - console.warning(f"Could not save metadata: {e}") + try: + result = subprocess.run( + ["sbatch"], + input=sbatch_script, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + console.detail( + f"Judge cleanup (pair {pair_idx})", + f"submitted ({result.stdout.strip()}), depends on grpo job {slurm_job_id}", + ) + else: + console.warning( + f"Failed to submit judge cleanup for pair {pair_idx}: " + f"{result.stderr.strip()}" + ) + except Exception as e: + console.warning(f"Could not submit judge cleanup for pair {pair_idx}: {e}") def execute( self, @@ -690,23 +890,108 @@ def execute( expname: str, run_after: list[str] | None = None, ) -> None: - """Prepare config, display summary, and submit GRPO training job.""" - prepared = self._prepare_grpo_config(config, cluster, expname, run_after=run_after) - self._display_grpo_summary(prepared, config) - self._submit_grpo_job(prepared, cluster, config) + """Prepare config, display summary, and submit GRPO training job. + + Single-environment mode (one env selected or only one defined): + Trains on that environment's data with a single ``data.train`` + entry. Output goes to ``{output_dir}/{env_name}/``. + + Multi-environment mode (multiple envs, via ``-e env1 env2`` or all): + Builds ``data.train`` as a list of per-environment dataset + entries (leveraging NeMo-RL's native multi-dataset support). + NeMo-Gym routes each sample to the correct agent via + ``agent_ref``. Output goes to ``{output_dir}/{env1+env2+...}/``. + """ + from nemo_skills.pipeline.utils.cluster import get_cluster_config + + from nvflow.lib.rl.helpers import resolve_environments + + environments = resolve_environments(config) + data_source_dir = config["data_source_dir"] + train_filename = config.get("train_filename", "train.jsonl") + val_filename = config.get("val_filename", "validation.jsonl") + cluster_config = get_cluster_config(cluster) + + if len(environments) == 1: + env_name = next(iter(environments)) + env_cfg = environments[env_name] + env_config = { + **config, + "output_dir": f"{config['output_dir']}/{env_name}", + "training_data": f"{data_source_dir}/{env_name}/{train_filename}", + "validation_data": f"{data_source_dir}/{env_name}/{val_filename}", + "environments": {env_name: env_cfg}, + "judge_vllm": env_cfg.get("judge_vllm") or {}, + } + console.status(f"Training for environment: {env_name}") + prepared = self._prepare_grpo_config( + env_config, + cluster, + f"{expname}-{env_name}", + run_after=run_after, + cluster_config=cluster_config, + ) + self._display_grpo_summary(prepared, env_config) + self._submit_grpo_job(prepared, cluster_config, env_config) + else: + env_names = list(environments.keys()) + combined_label = "+".join(env_names) + combined_dir = f"{config['output_dir']}/{combined_label}" + + train_datasets = [] + val_datasets = [] + for env_name, env_cfg in environments.items(): + train_entry: dict[str, Any] = { + "data_path": f"{data_source_dir}/{env_name}/{train_filename}", + } + repeat = env_cfg.get("training_repeat", 1) + if repeat > 1: + train_entry["repeat"] = repeat + train_datasets.append(train_entry) + val_datasets.append( + { + "data_path": f"{data_source_dir}/{env_name}/{val_filename}", + } + ) + + combined_judge_vllm: dict[str, Any] = {} + for ecfg in environments.values(): + jv = ecfg.get("judge_vllm") or {} + if jv.get("model_path") or jv.get("base_url") or jv.get("openai_base_url"): + combined_judge_vllm = jv + break + + combined_config = { + **config, + "output_dir": combined_dir, + "training_datasets": train_datasets, + "validation_datasets": val_datasets, + "environments": dict(environments), + "judge_vllm": combined_judge_vllm, + } + console.status(f"Training on combined environments: {combined_label}") + prepared = self._prepare_grpo_config( + combined_config, + cluster, + f"{expname}-{combined_label}", + run_after=run_after, + cluster_config=cluster_config, + ) + self._display_grpo_summary(prepared, combined_config) + self._submit_grpo_job(prepared, cluster_config, combined_config) def validate_config(self, config: dict[str, Any]) -> None: """Validate configuration.""" - required = ["output_dir", "model_name"] + required = ["output_dir", "model_name", "data_source_dir"] for required_field in required: if required_field not in config: raise ValueError(f"'{required_field}' is required in GRPO config") + if not config.get("environments"): + raise ValueError("'environments' dict is required in GRPO training config") + preset_name = config.get("preset") if preset_name and preset_name not in self.presets: raise ValueError( f"Unknown preset '{preset_name}'. Available: {', '.join(self.presets.keys())}" ) - - if config.get("dependent_jobs", 0) > 0 and not config.get("training_data"): - raise ValueError("'training_data' is required when dependent_jobs > 0.") diff --git a/nvflow/recipes/finance/stages/rl/validate_questions.py b/nvflow/recipes/finance/stages/rl/validate_questions.py new file mode 100644 index 0000000..a603914 --- /dev/null +++ b/nvflow/recipes/finance/stages/rl/validate_questions.py @@ -0,0 +1,186 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Validate SDG questions before they enter the GRPO pipeline. + +Two-phase per-environment pipeline: + +1. **Regex prefilter (CPU).** Drops questions that use "the company"/ + "the firm"/etc. with no named company or ticker anywhere in the text. + Intentionally narrow -- recall over precision. + +2. **LLM classifier (GPU).** For every question that survived Phase 1, + asks a judge model (GPT-OSS-120B by default) to return + ``Answer: VALID`` / ``Answer: INVALID``. A post-processing chain + parses the tag and splits records into kept (VALID) and dropped + (INVALID) streams. Parse failures default to VALID. + +The kept stream is written to ``{output_dir}/{env}/final_result.jsonl`` +so downstream ``data_transformation`` can consume it via the normal +``env.raw_train_data`` path (re-pointed at this stage's output in the +model config). + +Per-env output layout: + + {output_dir}/{env_name}/ + ├── final_result.jsonl <- VALID records, read by data_transformation + ├── phase1_regex/ + │ ├── prefiltered.jsonl <- regex kept + │ ├── regex_dropped.jsonl <- regex dropped (audit) + │ ├── prefilter_stats.json <- regex phase stats + │ └── logs/ <- regex Slurm logs + └── phase2_llm/ + ├── output.jsonl <- raw LLM generation (nemo-skills) + ├── parsed.jsonl <- LLM output with validate_tag attached + ├── parsed_parse_log.txt <- parse audit + ├── llm_dropped.jsonl <- LLM dropped (audit) + ├── llm_filter_stats.json <- LLM phase stats + └── generation-logs/ <- LLM Slurm logs (nemo-skills names this) +""" + +from typing import Any + +from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.vllm_compat import inject_server_entrypoint + +# Internal artefact names (not user-facing -- see module docstring for layout). +_PHASE1_SUBDIR = "phase1_regex" +_PHASE2_SUBDIR = "phase2_llm" +_PREFILTERED = "prefiltered.jsonl" +_REGEX_DROPPED = "regex_dropped.jsonl" +_PREFILTER_STATS = "prefilter_stats.json" +_LLM_GEN_OUTPUT = "output.jsonl" +_PARSED = "parsed.jsonl" +_LLM_DROPPED = "llm_dropped.jsonl" +_LLM_FILTER_STATS = "llm_filter_stats.json" + +_UTILS_MODULE = "nvflow.recipes.finance.utils.rl" + + +@StageRegistry.register(recipe="finance", workflow="grpo", stage="validate_questions") +class ValidateQuestionsStage(BaseStage): + """GRPO data-quality pre-filter that runs before data_transformation.""" + + workflow = "grpo" + + def execute( + self, + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None = None, + ) -> None: + from nemo_skills.pipeline.cli import generate, run_cmd, wrap_arguments + + from nvflow.lib.rl.helpers import resolve_environments + + environments = resolve_environments(config) + source_data = config["source_data"] + output_dir = config["output_dir"] + prompt_config = config["prompt_config"] + inline_args = config.get("inline_args", "") + source_filename = config.get("source_filename", "final_result.jsonl") + final_filename = config.get("final_filename", "final_result.jsonl") + + stage_kwargs = inject_server_entrypoint( + config.get("stage_kwargs", {}), + config.get("stage_kwargs", {}).get("model", ""), + ) + + console.status("Validating SDG questions before GRPO data_transformation") + console.detail("Source data", source_data) + console.detail("Output base dir", output_dir) + console.detail("Prompt config", str(prompt_config)) + console.detail("Environments", ", ".join(environments.keys())) + console.blank() + + for env_name in environments: + env_output_dir = f"{output_dir}/{env_name}" + phase1_dir = f"{env_output_dir}/{_PHASE1_SUBDIR}" + phase2_dir = f"{env_output_dir}/{_PHASE2_SUBDIR}" + source_file = f"{source_data}/{source_filename}" + prefiltered_file = f"{phase1_dir}/{_PREFILTERED}" + final_file = f"{env_output_dir}/{final_filename}" + + console.status(f"validate_questions: environment '{env_name}'") + console.detail("Input", source_file) + console.detail("Final output (VALID)", final_file) + console.blank() + + # Step A: regex prefilter (CPU) -> phase1_regex/. Script is + # skip-by-default when outputs already exist (rerun-safe after + # a phase 2 crash: prefiltered.jsonl is left untouched so phase + # 2's ``skip_filled=True`` resume by row index stays + # consistent). Pass ``--force`` here to bypass the skip. + regex_cmd = ( + f"python3 -m {_UTILS_MODULE}.regex_prefilter_questions" + f" --input_file '{source_file}'" + f" --output_kept '{prefiltered_file}'" + f" --output_dropped '{phase1_dir}/{_REGEX_DROPPED}'" + f" --stats_file '{phase1_dir}/{_PREFILTER_STATS}'" + ) + run_cmd( + ctx=wrap_arguments(regex_cmd), + cluster=cluster, + log_dir=f"{phase1_dir}/logs", + expname=f"{expname}-{env_name}-phase1-regex", + run_after=run_after, + ) + + # Step B: LLM classifier + postprocess chain -> phase2_llm/ and final_result.jsonl. + # Resume is controlled by ``++skip_filled=True`` in inline_args (see base.yaml): + # nemo-skills reads output.jsonl-async for already-filled indices and skips them. + parse_cmd = ( + f"python3 -m {_UTILS_MODULE}.parse_validate_responses" + f" --input_file '{phase2_dir}/{_LLM_GEN_OUTPUT}'" + f" --output_file '{phase2_dir}/{_PARSED}'" + ) + # --raw_sdg_source restores SDG-original reasoning_content per + # record (nemo-skills overwrites it with LLM provider reasoning). + apply_cmd = ( + f"python3 -m {_UTILS_MODULE}.apply_validate_filter" + f" --input_file '{phase2_dir}/{_PARSED}'" + f" --output_kept '{final_file}'" + f" --output_dropped '{phase2_dir}/{_LLM_DROPPED}'" + f" --stats_file '{phase2_dir}/{_LLM_FILTER_STATS}'" + f" --raw_sdg_source '{source_data}'" + f" --raw_sdg_filename '{source_filename}'" + ) + generate( + ctx=wrap_arguments(f"++prompt_config={prompt_config} {inline_args}".strip()), + cluster=cluster, + input_file=prefiltered_file, + output_dir=phase2_dir, + expname=f"{expname}-{env_name}-phase2-llm", + run_after=[f"{expname}-{env_name}-phase1-regex"], + postprocess_cmd=f"{parse_cmd} && {apply_cmd}", + **stage_kwargs, + ) + + console.success(f"validate_questions submitted for '{env_name}' -> {final_file}") + + def validate_config(self, config: dict[str, Any]) -> None: + """Basic sanity checks before Slurm submission.""" + for field in ("output_dir", "source_data", "prompt_config", "environments"): + if not config.get(field): + raise ValueError( + f"stages.validate_questions.{field} is required " + "(see validate_questions stage docstring for details)." + ) + stage_kwargs = config.get("stage_kwargs") or {} + if not stage_kwargs.get("model"): + raise ValueError( + "stages.validate_questions.stage_kwargs.model is required " + "(e.g., /hf_models/openai/gpt-oss-120b)." + ) diff --git a/nvflow/recipes/finance/stages/sdg/__init__.py b/nvflow/recipes/finance/stages/sdg/__init__.py index 7af9f5d..3ced14d 100644 --- a/nvflow/recipes/finance/stages/sdg/__init__.py +++ b/nvflow/recipes/finance/stages/sdg/__init__.py @@ -19,6 +19,6 @@ _current_dir = Path(__file__).parent for file in _current_dir.glob("*.py"): - if file.stem.startswith("_"): + if file.name.startswith(".") or file.stem.startswith("_"): continue importlib.import_module(f".{file.stem}", package=__package__) diff --git a/nvflow/recipes/finance/stages/sdg/aggregate_answers.py b/nvflow/recipes/finance/stages/sdg/aggregate_answers.py index becf6e6..7f39179 100644 --- a/nvflow/recipes/finance/stages/sdg/aggregate_answers.py +++ b/nvflow/recipes/finance/stages/sdg/aggregate_answers.py @@ -65,11 +65,11 @@ def execute( # Input file stem is "selected_answers" based on workflow config generation_folder = Path(input_dir) / "selected_answers" - aggregate_script = "/workspace/nvflow/recipes/finance/utils/sdg/aggregate_evaluate.py" + aggregate_module = "nvflow.recipes.finance.utils.sdg.aggregate_evaluate" # Aggregate results (parse + aggregate combined, no intermediate files) full_cmd = ( - f"python {aggregate_script} " + f"python3 -m {aggregate_module} " f"--input_dir {generation_folder} " f"--output_file {output_file} " f"--num_seeds {num_seeds}" @@ -77,15 +77,11 @@ def execute( console.status("Running aggregation (streaming, no intermediate files)") - preprocess_kwargs = config.get("preprocess_kwargs", {}) - partition = preprocess_kwargs.get("partition", "cpu") - run_cmd( ctx=wrap_arguments(full_cmd), cluster=cluster, expname=expname, run_after=run_after, - partition=partition, ) console.success("Completed aggregation") diff --git a/nvflow/recipes/finance/stages/sdg/dg_sdg_preprocess.py b/nvflow/recipes/finance/stages/sdg/dg_sdg_preprocess.py index b246d4e..c0ada3f 100644 --- a/nvflow/recipes/finance/stages/sdg/dg_sdg_preprocess.py +++ b/nvflow/recipes/finance/stages/sdg/dg_sdg_preprocess.py @@ -75,10 +75,10 @@ def execute( console.detail("Seed", str(seed)) console.blank() - preprocess_script = "/workspace/nvflow/recipes/finance/utils/sdg/dg_sdg_data_preprocess.py" + preprocess_module = "nvflow.recipes.finance.utils.sdg.dg_sdg_data_preprocess" full_cmd = ( - f"python {preprocess_script} " + f"python3 -m {preprocess_module} " f"--input_dir {input_dir} " f"--output_dir {output_dir} " f"--distribution_dir {distribution_dir} " @@ -91,15 +91,11 @@ def execute( console.status("Running SEC data preprocessing") - preprocess_kwargs = config.get("preprocess_kwargs", {}) - partition = preprocess_kwargs.get("partition", "cpu") - run_cmd( ctx=wrap_arguments(full_cmd), cluster=cluster, expname=expname, run_after=run_after, - partition=partition, ) console.success("SEC data preprocessing job submitted") diff --git a/nvflow/recipes/finance/stages/sdg/difficulty_estimation.py b/nvflow/recipes/finance/stages/sdg/difficulty_estimation.py index 4439599..2bb4eda 100644 --- a/nvflow/recipes/finance/stages/sdg/difficulty_estimation.py +++ b/nvflow/recipes/finance/stages/sdg/difficulty_estimation.py @@ -17,6 +17,7 @@ from typing import Any from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.vllm_compat import inject_server_entrypoint @StageRegistry.register( @@ -78,24 +79,22 @@ def execute( judge_input_dir = f"{work_dir}/judge_inputs" judge_output_dir = f"{work_dir}/judged" - script_path = "/workspace/nvflow/recipes/finance/utils/sdg/difficulty_estimation.py" + module = "nvflow.recipes.finance.utils.sdg.difficulty_estimation" # ===================================================================== # Step 1: Prepare input (keep reference_answer for later comparison) # ===================================================================== console.status("Step 1/4: Preparing input for small model") - prep_cmd = f"python {script_path} prepare_input --input_file {input_file} --output_file {prep_file}" - - preprocess_kwargs = config.get("preprocess_kwargs", {}) - partition = preprocess_kwargs.get("partition", "cpu") + prep_cmd = ( + f"python3 -m {module} prepare_input --input_file {input_file} --output_file {prep_file}" + ) run_cmd( ctx=wrap_arguments(prep_cmd), cluster=cluster, expname=f"{expname}-prep", run_after=run_after, - partition=partition, ) console.success("Step 1 job submitted") @@ -105,6 +104,9 @@ def execute( console.status(f"Step 2/4: Generating answers with small model ({num_seeds} seeds)") answer_args = answer_model_kwargs.get("args", {}).copy() + answer_args = inject_server_entrypoint( + answer_args, answer_args.get("model", "") + ) # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) answer_ctx_args = answer_model_kwargs.get("ctx_args", "") if answer_prompt: @@ -130,14 +132,13 @@ def execute( console.status("Step 3/4: Preparing input for judge model") max_answer_chars = config.get("max_answer_chars", 20000) - judge_prep_cmd = f"python {script_path} prepare_judge --input_dir {answer_dir} --output_dir {judge_input_dir} --max_answer_chars {max_answer_chars}" + judge_prep_cmd = f"python3 -m {module} prepare_judge --input_dir {answer_dir} --output_dir {judge_input_dir} --max_answer_chars {max_answer_chars}" run_cmd( ctx=wrap_arguments(judge_prep_cmd), cluster=cluster, expname=f"{expname}-judge-prep", run_after=[f"{expname}-answer"], - partition=partition, ) console.success("Step 3 job submitted") @@ -147,6 +148,9 @@ def execute( console.status(f"Step 4/5: Judging answers ({num_seeds} separate jobs)") judge_args = judge_model_kwargs.get("args", {}).copy() + judge_args = inject_server_entrypoint( + judge_args, judge_args.get("model", "") + ) # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) judge_ctx_args = judge_model_kwargs.get("ctx_args", "") if judge_prompt: @@ -181,14 +185,13 @@ def execute( # Wait for all judge jobs to complete judge_job_names = [f"{expname}-judge-rs{i}" for i in range(num_seeds)] - aggregate_cmd = f"python {script_path} aggregate --input_dir {judge_output_dir} --output_file {output_file} --num_seeds {num_seeds}" + aggregate_cmd = f"python3 -m {module} aggregate --input_dir {judge_output_dir} --output_file {output_file} --num_seeds {num_seeds}" run_cmd( ctx=wrap_arguments(aggregate_cmd), cluster=cluster, expname=expname, # Use base expname for downstream dependencies run_after=judge_job_names, - partition=partition, ) console.success("Step 5 job submitted") diff --git a/nvflow/recipes/finance/stages/sdg/document_grounded_data.py b/nvflow/recipes/finance/stages/sdg/document_grounded_data.py index 13c3cb9..360ab0c 100644 --- a/nvflow/recipes/finance/stages/sdg/document_grounded_data.py +++ b/nvflow/recipes/finance/stages/sdg/document_grounded_data.py @@ -61,24 +61,17 @@ def execute( console.detail("Random seed", str(seed)) console.blank() - script_path = "/workspace/nvflow/recipes/finance/utils/sdg/dgsdg_post_process.py" + module = "nvflow.recipes.finance.utils.sdg.dgsdg_post_process" cmd = ( - f"python {script_path} " - f"--input_file {input_file} " - f"--output_dir {output_dir} " - f"--seed {seed}" + f"python3 -m {module} --input_file {input_file} --output_dir {output_dir} --seed {seed}" ) - preprocess_kwargs = config.get("preprocess_kwargs", {}) - partition = preprocess_kwargs.get("partition", "cpu") - run_cmd( ctx=wrap_arguments(cmd), cluster=cluster, expname=expname, run_after=run_after, - partition=partition, ) console.success("Document grounded sdg data post processing job submitted") diff --git a/nvflow/recipes/finance/stages/sdg/document_grounded_question_answer_generation_pipeline.py b/nvflow/recipes/finance/stages/sdg/document_grounded_question_answer_generation_pipeline.py index b667726..4c4dec1 100644 --- a/nvflow/recipes/finance/stages/sdg/document_grounded_question_answer_generation_pipeline.py +++ b/nvflow/recipes/finance/stages/sdg/document_grounded_question_answer_generation_pipeline.py @@ -17,6 +17,7 @@ from typing import Any from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.vllm_compat import inject_server_entrypoint @StageRegistry.register( @@ -52,7 +53,6 @@ def execute( output_dir = config["output_dir"] # Question pipeline config - question_preprocess_kwargs = config.get("question_preprocess_kwargs", {}) question_generation_kwargs = config.get("question_generation_kwargs", {}) question_verify_kwargs = config.get("question_verify_kwargs", {}) @@ -73,7 +73,7 @@ def execute( a_generate_input_file = f"{answer_output_dir}/answer_input.jsonl" a_generate_output_dir = f"{answer_output_dir}/generated" - script_path = "/workspace/nvflow/recipes/finance/utils/sdg/document_grounded_preprocess.py" + module = "nvflow.recipes.finance.utils.sdg.document_grounded_preprocess" # ===================================================================== # Step 1: Construct question generate input @@ -82,16 +82,13 @@ def execute( console.detail("Input folder", input_folder) console.detail("Output file", q_generate_input_file) - partition = question_preprocess_kwargs.get("partition", "cpu") - - cmd = f"python {script_path} construct_question_generate_input --input_folder {input_folder} --output_file {q_generate_input_file}" + cmd = f"python3 -m {module} construct_question_generate_input --input_folder {input_folder} --output_file {q_generate_input_file}" run_cmd( ctx=wrap_arguments(cmd), cluster=cluster, expname=f"{expname}-step1-q-prep", run_after=run_after, - partition=partition, ) console.success("Step 1 job submitted") @@ -101,6 +98,9 @@ def execute( console.status("Step 2/6: Generating questions") q_gen_args = question_generation_kwargs.get("args", {}).copy() + q_gen_args = inject_server_entrypoint( + q_gen_args, q_gen_args.get("model", "") + ) # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) q_gen_ctx_args = question_generation_kwargs.get("ctx_args", "") # Handle skip_filled @@ -125,14 +125,13 @@ def execute( # ===================================================================== console.status("Step 3/6: Preparing data for question verification") - cmd = f"python {script_path} construct_question_verify_input --input_dir {q_generate_output_dir} --output_file {q_verify_input_file}" + cmd = f"python3 -m {module} construct_question_verify_input --input_dir {q_generate_output_dir} --output_file {q_verify_input_file}" run_cmd( ctx=wrap_arguments(cmd), cluster=cluster, expname=f"{expname}-step3-q-verify-prep", run_after=[f"{expname}-step2-q-gen"], - partition=partition, ) console.success("Step 3 job submitted") @@ -142,6 +141,9 @@ def execute( console.status("Step 4/6: Verifying questions") verify_args = question_verify_kwargs.get("args", {}).copy() + verify_args = inject_server_entrypoint( + verify_args, verify_args.get("model", "") + ) # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) verify_ctx_args = question_verify_kwargs.get("ctx_args", "") # Handle skip_filled @@ -171,18 +173,16 @@ def execute( console.detail("Input dir", answer_input_dir) console.detail("Output file", a_generate_input_file) - partition = answer_preprocess_kwargs.get("partition", "cpu") threshold = answer_preprocess_kwargs.get("threshold", 0.5) sbatch_kwargs = answer_preprocess_kwargs.get("sbatch_kwargs", "") - cmd = f"python {script_path} construct_answer_generate_input --input_dir {answer_input_dir} --output_file {a_generate_input_file} --threshold {threshold}" + cmd = f"python3 -m {module} construct_answer_generate_input --input_dir {answer_input_dir} --output_file {a_generate_input_file} --threshold {threshold}" run_cmd( ctx=wrap_arguments(cmd), cluster=cluster, expname=f"{expname}-step5-a-prep", run_after=[f"{expname}-step4-q-verify"], - partition=partition, sbatch_kwargs=sbatch_kwargs, ) console.success("Step 5 job submitted") @@ -194,6 +194,9 @@ def execute( console.status("Step 6/6: Generating answers") a_gen_args = answer_generation_kwargs.get("args", {}).copy() + a_gen_args = inject_server_entrypoint( + a_gen_args, a_gen_args.get("model", "") + ) # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) a_gen_ctx_args = answer_generation_kwargs.get("ctx_args", "") # Handle skip_filled diff --git a/nvflow/recipes/finance/stages/sdg/evaluate_answers.py b/nvflow/recipes/finance/stages/sdg/evaluate_answers.py index 805a1da..d4c69ab 100644 --- a/nvflow/recipes/finance/stages/sdg/evaluate_answers.py +++ b/nvflow/recipes/finance/stages/sdg/evaluate_answers.py @@ -18,6 +18,7 @@ from typing import Any from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.vllm_compat import inject_server_entrypoint @StageRegistry.register( @@ -59,7 +60,10 @@ def execute( output_file = config.get("output_file") prompt_config = config.get("prompt_config") inline_args = config.get("inline_args", "") - stage_kwargs = config.get("stage_kwargs", {}) + stage_kwargs = inject_server_entrypoint( # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) + config.get("stage_kwargs", {}), + config.get("stage_kwargs", {}).get("model", ""), + ) num_random_seeds = stage_kwargs.get("num_random_seeds", 1) console.status("Evaluating answers for correctness and answerability") @@ -78,7 +82,7 @@ def execute( console.detail("Generation folder", str(generation_folder)) - script_path = "/workspace/nvflow/recipes/finance/utils/sdg/parse_evaluate_responses.py" + module = "nvflow.recipes.finance.utils.sdg.parse_evaluate_responses" console.status("Running LLM generation to evaluate answers") ctx = wrap_arguments(f"++prompt_config={prompt_config} {inline_args}") @@ -104,8 +108,8 @@ def execute( output_file if output_file else str(generation_folder / "evaluated.jsonl") ) - parse_cmd = f"python {script_path} parse --input_file {generated_file} --output_file {parsed_file}" - filter_cmd = f"python {script_path} filter --input_file {parsed_file} --output_file {final_output}" + parse_cmd = f"python3 -m {module} parse --input_file {generated_file} --output_file {parsed_file}" + filter_cmd = f"python3 -m {module} filter --input_file {parsed_file} --output_file {final_output}" postprocess_cmd = f"{parse_cmd} && {filter_cmd}" generate( diff --git a/nvflow/recipes/finance/stages/sdg/filter_answers.py b/nvflow/recipes/finance/stages/sdg/filter_answers.py index 9e9655a..aa3090c 100644 --- a/nvflow/recipes/finance/stages/sdg/filter_answers.py +++ b/nvflow/recipes/finance/stages/sdg/filter_answers.py @@ -18,6 +18,7 @@ from typing import Any from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.vllm_compat import inject_server_entrypoint # Run with uv run nflow run filter_answers --config=nvflow/recipes/finance/workflows/sdg/template-based-sdg.yaml @@ -66,13 +67,13 @@ def execute( # Step 1: Parse LLM responses to extract filter tags parse_cmd = ( - f"python /workspace/nvflow/recipes/finance/utils/sdg/parse_filter_responses.py " + f"python3 -m nvflow.recipes.finance.utils.sdg.parse_filter_responses " f"--input_file {generated_file} --output_file {parsed_file}" ) # Step 2: Apply filter to keep only ANSWERABLE entries filter_cmd = ( - f"python /workspace/nvflow/recipes/finance/utils/sdg/apply_answer_filter.py " + f"python3 -m nvflow.recipes.finance.utils.sdg.apply_answer_filter " f"--input_file {parsed_file} --output_file {output_file} --keep_tag ANSWERABLE" ) @@ -81,6 +82,10 @@ def execute( console.status("Running LLM generation to tag answers") + stage_kwargs = inject_server_entrypoint( # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) + config.get("stage_kwargs", {}), + config.get("stage_kwargs", {}).get("model", ""), + ) ctx = wrap_arguments(f"++prompt_config={prompt_config} {inline_args}") generate( ctx=ctx, @@ -89,7 +94,7 @@ def execute( output_dir=str(generation_folder), expname=expname, run_after=run_after, - **config.get("stage_kwargs", {}), + **stage_kwargs, rerun_done=True, postprocess_cmd=postprocess_cmd, ) diff --git a/nvflow/recipes/finance/stages/sdg/generate_answers.py b/nvflow/recipes/finance/stages/sdg/generate_answers.py index ecc9855..97d6976 100644 --- a/nvflow/recipes/finance/stages/sdg/generate_answers.py +++ b/nvflow/recipes/finance/stages/sdg/generate_answers.py @@ -17,6 +17,7 @@ from typing import Any from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.vllm_compat import inject_server_entrypoint # Run with uv run nflow run generate_answers --config=nvflow/recipes/finance/workflows/sdg/template-based-sdg.yaml @@ -50,6 +51,10 @@ def execute( console.detail("Inline args", str(inline_args)) console.blank() + stage_kwargs = inject_server_entrypoint( # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) + config.get("stage_kwargs", {}), + config.get("stage_kwargs", {}).get("model", ""), + ) ctx = wrap_arguments(f"++prompt_config={prompt_config} {inline_args}") generate( ctx=ctx, @@ -58,7 +63,7 @@ def execute( output_dir=output_dir, expname=expname, run_after=run_after, - **config.get("stage_kwargs", {}), + **stage_kwargs, rerun_done=True, ) diff --git a/nvflow/recipes/finance/stages/sdg/generate_questions.py b/nvflow/recipes/finance/stages/sdg/generate_questions.py index 7b3b3d2..bdacf7b 100644 --- a/nvflow/recipes/finance/stages/sdg/generate_questions.py +++ b/nvflow/recipes/finance/stages/sdg/generate_questions.py @@ -18,6 +18,7 @@ from typing import Any from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.vllm_compat import inject_server_entrypoint # Run with uv run nflow run generate_questions --config=nvflow/recipes/finance/workflows/sdg/template-based-sdg.yaml @@ -67,7 +68,7 @@ def execute( run_cmd( ctx=wrap_arguments( f"pip install -q --root-user-action=ignore jsonlines && " - f"python /workspace/nvflow/recipes/finance/utils/sdg/prepare_question_gen_data.py " + f"python3 -m nvflow.recipes.finance.utils.sdg.prepare_question_gen_data " f"--input_file {input_file} --company_info_file {company_info_file} " f"--start_year {start_year} --end_year {end_year} --output_file {prepped_file}" ), @@ -81,8 +82,12 @@ def execute( generated_file = str(generation_folder / "output.jsonl") - postprocess_cmd = f"python /workspace/nvflow/recipes/finance/utils/sdg/parse_generated_questions.py --input_file {generated_file} --output_file {output_file}" + postprocess_cmd = f"python3 -m nvflow.recipes.finance.utils.sdg.parse_generated_questions --input_file {generated_file} --output_file {output_file}" + stage_kwargs = inject_server_entrypoint( # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) + config.get("stage_kwargs", {}), + config.get("stage_kwargs", {}).get("model", ""), + ) ctx = wrap_arguments(f"++prompt_config={prompt_config} {inline_args}") generate( ctx=ctx, @@ -91,7 +96,7 @@ def execute( output_dir=str(generation_folder), expname=expname, run_after=[f"{expname}-prep"], - **config.get("stage_kwargs", {}), + **stage_kwargs, rerun_done=True, postprocess_cmd=postprocess_cmd, ) diff --git a/nvflow/recipes/finance/stages/sdg/genselect_answers.py b/nvflow/recipes/finance/stages/sdg/genselect_answers.py index 6f43566..5429c5c 100644 --- a/nvflow/recipes/finance/stages/sdg/genselect_answers.py +++ b/nvflow/recipes/finance/stages/sdg/genselect_answers.py @@ -17,6 +17,7 @@ from typing import Any from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.vllm_compat import inject_server_entrypoint # Run with uv run nflow run genselect_answers --config=nvflow/recipes/finance/workflows/sdg/template-based-sdg.yaml @@ -63,7 +64,7 @@ def execute( console.status("Step 1: Preparing genselect data") run_cmd( ctx=wrap_arguments( - f"python /workspace/nvflow/recipes/finance/utils/sdg/prepare_genselect_data.py --input_dir={input_dir} --output_file={prepped_file}" + f"python3 -m nvflow.recipes.finance.utils.sdg.prepare_genselect_data --input_dir={input_dir} --output_file={prepped_file}" ), cluster=cluster, expname=f"{expname}-prep", @@ -71,10 +72,14 @@ def execute( run_after=run_after, ) - postprocess_cmd = f"python /workspace/nvflow/recipes/finance/utils/sdg/postprocess_genselect.py --input_dir={output_dir} --output_file={output_file}" + postprocess_cmd = f"python3 -m nvflow.recipes.finance.utils.sdg.postprocess_genselect --input_dir={output_dir} --output_file={output_file}" console.status("Generating answers with genselect") + stage_kwargs = inject_server_entrypoint( # WORKAROUND(vllm-0.17-hermes, harmony-aarch64) + config.get("stage_kwargs", {}), + config.get("stage_kwargs", {}).get("model", ""), + ) ctx = wrap_arguments(f"++prompt_config={prompt_config} {inline_args}") generate( ctx=ctx, @@ -83,7 +88,7 @@ def execute( output_dir=output_dir, expname=expname, run_after=[f"{expname}-prep"], - **config.get("stage_kwargs", {}), + **stage_kwargs, rerun_done=True, postprocess_cmd=postprocess_cmd, ) diff --git a/nvflow/recipes/finance/stages/sdg/map_questions_to_context.py b/nvflow/recipes/finance/stages/sdg/map_questions_to_context.py index 9ca1c00..aa6f871 100644 --- a/nvflow/recipes/finance/stages/sdg/map_questions_to_context.py +++ b/nvflow/recipes/finance/stages/sdg/map_questions_to_context.py @@ -59,7 +59,7 @@ def execute( run_cmd( ctx=wrap_arguments( f"pip install -q --root-user-action=ignore jsonlines tiktoken markdownify && " - f"python /workspace/nvflow/recipes/finance/utils/shared/question_context_utils.py --input_file {input_file} --filings_metadata {filings_metadata} --filings_dir {filings_dir} --output_file {output_file} --token_limit {token_limit}" + f"python3 -m nvflow.recipes.finance.utils.shared.question_context_utils --input_file {input_file} --filings_metadata {filings_metadata} --filings_dir {filings_dir} --output_file {output_file} --token_limit {token_limit}" ), cluster=cluster, **config.get("stage_kwargs", {}), diff --git a/nvflow/recipes/finance/stages/sft/__init__.py b/nvflow/recipes/finance/stages/sft/__init__.py index 95b6506..6ec58b1 100644 --- a/nvflow/recipes/finance/stages/sft/__init__.py +++ b/nvflow/recipes/finance/stages/sft/__init__.py @@ -19,6 +19,6 @@ _current_dir = Path(__file__).parent for file in _current_dir.glob("*.py"): - if file.stem.startswith("_"): + if file.name.startswith(".") or file.stem.startswith("_"): continue importlib.import_module(f".{file.stem}", package=__package__) diff --git a/nvflow/recipes/finance/stages/sft/training.py b/nvflow/recipes/finance/stages/sft/training.py index d3de70b..a172f84 100644 --- a/nvflow/recipes/finance/stages/sft/training.py +++ b/nvflow/recipes/finance/stages/sft/training.py @@ -14,24 +14,25 @@ # """Supervised Fine-Tuning for financial reasoning models. -This module handles SFT training using NeMo-RL's native config format. -Config is passed directly to nemo_rl.algorithms.sft.SFTTrainer without translation. +This module handles SFT training by calling the RL repo's run_sft.py directly +(same execution pattern as GRPO), bypassing nemo-skills' start_sft.py wrapper. + +The full NeMo-RL config is base64-encoded into the Slurm command and decoded +at job runtime. Runtime overrides (model_name, cluster, data paths, etc.) +are applied via ++key=value CLI args. File organization: 1. Configuration Schemas - Data classes for prepared configs 2. SFTStage Class: a. Preset Loading - Load sft_presets.yaml - b. Config Flattening - Convert dicts to Hydra overrides - c. Parallelism Helpers - Unified extraction of TP/PP/CP/EP - d. Validation & Auto-Correction - Check configs before submission - e. Main Config Preparation - Merge preset + overrides, validate - f. Job Submission & Display - Submit to nemo-skills - g. Metadata Saving - Save run metadata for reproducibility + b. Parallelism Helpers - Unified extraction of TP/PP/CP/EP + c. Validation & Auto-Correction - Check configs before submission + d. Main Config Preparation - Merge preset + overrides, validate + e. Job Submission & Display - Submit via add_task/get_exp/run_exp """ -import os +import base64 from dataclasses import dataclass -from datetime import datetime from pathlib import Path from typing import Any @@ -39,6 +40,7 @@ from omegaconf import OmegaConf from nvflow.core import BaseStage, StageRegistry, console +from nvflow.lib.gpu_layout import resolve_gpu_layout # ============================================================================ # Configuration Schemas @@ -167,50 +169,9 @@ def _is_nemo_rl_preset(self, preset: dict) -> bool: return bool(set(preset.keys()) & self.NEMO_RL_PRESET_KEYS) # ======================================================================== - # Config Flattening & Resolution + # Config Resolution # ======================================================================== - def _flatten_config_to_args(self, config: dict, prefix: str = "") -> list[str]: - """Recursively flatten config dict to Hydra override args. - - Converts nested dict like: - {"policy": {"dtensor_cfg": {"tensor_parallel_size": 2}}} - To: - ["++policy.dtensor_cfg.tensor_parallel_size=2"] - - Skips complex nested structures (list of dicts) which should be in YAML presets. - Skips env_vars dict to avoid Hydra type conversion issues (stays in merged YAML). - """ - args = [] - for key, value in config.items(): - # Skip description field (not a NeMo-RL config) - if key == "description": - continue - - # Skip env_vars dict - it should stay in the merged config YAML - # Flattening to CLI args causes Hydra to convert string values like "720" to int - if key == "env_vars" and isinstance(value, dict): - continue - - full_key = f"{prefix}.{key}" if prefix else key - - if isinstance(value, dict): - # Recurse into nested dicts - args.extend(self._flatten_config_to_args(value, full_key)) - elif isinstance(value, bool): - args.append(f"++{full_key}={str(value).lower()}") - elif isinstance(value, list): - # Skip complex nested structures (e.g., scheduler with list of dicts) - # These should be defined in sft_presets.yaml, not flattened to CLI args - if value and isinstance(value[0], dict): - continue - # Simple lists like betas: [0.9, 0.98] → "[0.9,0.98]" - list_str = "[" + ",".join(str(v) for v in value) + "]" - args.append(f"++{full_key}={list_str}") - elif value is not None: - args.append(f"++{full_key}={value}") - return args - def _resolve_nemo_rl_config(self, config: dict) -> dict: """Resolve NeMo-RL format preset with overrides.""" preset_name = config.get("preset") @@ -255,6 +216,52 @@ def _auto_correct_sequence_parallel(self, nemo_rl_config: dict, backend: str) -> ) cfg["sequence_parallel"] = False + def _auto_correct_sequence_length_divisibility( + self, nemo_rl_config: dict, backend: str + ) -> None: + """Auto-compute policy.make_sequence_length_divisible_by from parallelism settings. + + Megatron splits individual sequences across CP and TP (when SP=true) ranks, + requiring sequence lengths to be divisible by a minimum pad factor: + - CP > 1 contributes cp_size * 2 (send/receive pattern) + - TP > 1 + SP=true contributes tp_size + + Before NeMo-RL PR #2053, this was auto-computed internally. That PR + changed it to a user-provided validated parameter (for GRPO top-p/top-k + sampling which needs higher alignment). For SFT (no sampling), the + minimum is always sufficient, so we restore auto-computation here. + + If the user explicitly set a higher value (e.g., for FP8 alignment), + it is preserved. + """ + policy = nemo_rl_config.get("policy", {}) + parallel = self._get_parallelism_config(policy, backend) + + if backend == "fsdp": + cfg = policy.get("dtensor_cfg", {}) + else: + cfg = policy.get("megatron_cfg", {}) + + tp = parallel["tp"] + cp = parallel["cp"] + sp = cfg.get("sequence_parallel", False) + + minimum = 1 + if cp > 1: + minimum *= cp * 2 + if tp > 1 and sp: + minimum *= tp + + current = policy.get("make_sequence_length_divisible_by", 1) + corrected = max(current, minimum) + + if corrected != current: + console.detail( + "Auto-corrected make_sequence_length_divisible_by", + f"{current} → {corrected} (CP={cp}, TP={tp}, SP={sp})", + ) + policy["make_sequence_length_divisible_by"] = corrected + def _validate_parallelism_config( self, nemo_rl_config: dict, backend: str, num_nodes: int, num_gpus: int ) -> None: @@ -392,25 +399,13 @@ def _validate_sequence_packing_for_cp(self, nemo_rl_config: dict, backend: str) f" train_mb_tokens: {policy.get('max_total_sequence_length', 4096)}" ) - def _build_nemo_rl_training_args(self, nemo_rl_config: dict) -> str: - """Build training arguments from NeMo-RL format config. - - Simply flattens the config dict to Hydra override args. - - Args: - nemo_rl_config: NeMo-RL format config dict - - Returns: - Formatted arguments string - """ - args = self._flatten_config_to_args(nemo_rl_config) - return " ".join(args) - # ======================================================================== # Main Configuration Preparation # ======================================================================== - def _prepare_nemo_rl_config(self, config: dict[str, Any], expname: str) -> PreparedNemoRLConfig: + def _prepare_nemo_rl_config( + self, config: dict[str, Any], expname: str, cluster_config: dict | None = None + ) -> PreparedNemoRLConfig: """Prepare training configuration for NeMo-RL format presets. Steps: @@ -419,10 +414,13 @@ def _prepare_nemo_rl_config(self, config: dict[str, Any], expname: str) -> Prepa 3. Validate parallelism configuration 4. Generate run name and prepare job submission parameters """ - # Extract basic config hf_model_name = config["model_name"] - num_nodes = config.get("num_nodes", 1) - num_gpus = config.get("num_gpus", 8) + layout = resolve_gpu_layout(config, cluster_config) + num_nodes = layout.num_nodes + num_gpus = layout.gpus_per_node + console.detail( + "GPU layout", f"{num_nodes} node(s) x {num_gpus} GPUs = {layout.total_gpus} total" + ) # Default to megatron backend (more scalable for large models) # Switch to fsdp only when megatron is not supported or has issues backend = config.get("backend", "megatron") @@ -432,6 +430,7 @@ def _prepare_nemo_rl_config(self, config: dict[str, Any], expname: str) -> Prepa # Step 2: Auto-correct invalid settings self._auto_correct_sequence_parallel(nemo_rl_config, backend) + self._auto_correct_sequence_length_divisibility(nemo_rl_config, backend) # Step 3: Validate parallelism configuration self._validate_parallelism_config(nemo_rl_config, backend, num_nodes, num_gpus) @@ -445,10 +444,10 @@ def _prepare_nemo_rl_config(self, config: dict[str, Any], expname: str) -> Prepa seq_len = policy.get("max_total_sequence_length", 131072) seq_k = seq_len // 1024 - # Generate run name: model-{name}-{nodes}n-tp{tp}-pp{pp}-cp{cp}-seq{seq}k + # Generate run name: model-{name}-{total_gpus}g-tp{tp}-pp{pp}-cp{cp}-seq{seq}k model_short = Path(hf_model_name).name.lower().replace("_", "-") run_name = ( - f"model-{model_short}-{num_nodes}n-" + f"model-{model_short}-{layout.total_gpus}g-" f"tp{parallel['tp']}-pp{parallel['pp']}-cp{parallel['cp']}-seq{seq_k}k" ) @@ -529,123 +528,134 @@ def _display_nemo_rl_summary(self, prepared: PreparedNemoRLConfig) -> None: ) console.blank() - def _submit_nemo_rl_job( + def _config_shell_snippet(self, prepared: PreparedNemoRLConfig) -> tuple[str, str]: + """Return a shell snippet that writes the NeMo-RL config YAML at job runtime. + + The config is base64-encoded and decoded inside the Slurm job, + avoiding any host-side filesystem writes. + """ + content = yaml.dump( + prepared.nemo_rl_config, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + encoded = base64.b64encode(content.encode()).decode() + config_path = f"{prepared.output_dir}/sft_config.yaml" + snippet = f"mkdir -p {prepared.output_dir} && echo {encoded} | base64 -d > {config_path}" + return snippet, config_path + + def _build_train_cmd( self, prepared: PreparedNemoRLConfig, - cluster: str, config: dict[str, Any], - run_after: list[str] | None = None, - ) -> None: - """Submit SFT training job using NeMo-RL format config.""" - from nemo_skills.pipeline.cli import sft_nemo_rl, wrap_arguments + config_snippet: str, + config_path: str, + cluster_config: dict, + ) -> str: + """Build the training command string for run_sft.py.""" + from nemo_skills.pipeline.nemo_rl.grpo import get_timeout_str - self._save_nemo_rl_metadata(prepared) - - # Build training arguments by flattening the NeMo-RL config - args = self._build_nemo_rl_training_args(prepared.nemo_rl_config) + stage_kwargs = config.get("stage_kwargs", {}) + partition = stage_kwargs.get("partition") + timeout = get_timeout_str(cluster_config, partition) + hf_model = config.get("hf_checkpoint_path", config["model_name"]) + + cmd = ( + f"{config_snippet} && " + f"export PYTHONPATH=$PYTHONPATH:/nemo_run/code:/opt/NeMo-RL && " + f"export UV_PROJECT=/opt/NeMo-RL && " + f"echo 'Starting training' && " + f"uv run --active python /opt/NeMo-RL/examples/run_sft.py " + f" --config {config_path}" + f" ++policy.model_name={hf_model}" + f" ++cluster.gpus_per_node={prepared.num_gpus}" + f" ++cluster.num_nodes={prepared.num_nodes}" + f" ++checkpointing.checkpoint_must_save_by={timeout}" + f" ++logger.log_dir={prepared.output_dir}/training-logs" + f" ++checkpointing.checkpoint_dir={prepared.output_dir}/checkpoints" + ) - # Debug: Display generated training arguments - console.blank() - console.info("=" * 80) - console.info("GENERATED TRAINING ARGUMENTS (Hydra overrides):") - console.info("-" * 80) - # Split args for readability (show each ++key=value on separate line) - for arg in args.split(" ++"): - if arg.startswith("++"): - console.info(f" {arg}") - elif arg: - console.info(f" ++{arg}") - console.info("=" * 80) - console.blank() + if prepared.backend == "megatron": + cmd += " ++policy.dtensor_cfg.enabled=false ++policy.megatron_cfg.enabled=true" + cmd += " ++policy.optimizer=None ++policy.dynamic_batching.enabled=false" + else: + cmd += " ++policy.dtensor_cfg.enabled=true ++policy.megatron_cfg.enabled=false" - # Add W&B configuration to training args - if prepared.wandb_mode == "disabled": - args = f"{args} ++logger.wandb_enabled=false" - elif prepared.wandb_mode == "offline": - args = f"{args} ++logger.wandb_enabled=true ++logger.wandb_mode=offline" + if config.get("training_data"): + cmd += f" ++data.train.data_path={config['training_data']}" + if config.get("validation_data"): + cmd += f" ++data.validation.data_path={config['validation_data']}" + else: + cmd += " ++data.validation=null" + + wandb_mode = config.get("wandb_mode", "disabled") + if wandb_mode == "disabled": + cmd += " ++logger.wandb_enabled=false" + elif wandb_mode == "offline": + cmd += " ++logger.wandb_enabled=true ++logger.wandb_mode=offline" + elif wandb_mode == "online": + wandb_project = config.get("wandb_project", "finance-sft") + cmd += ( + f" ++logger.wandb_enabled=true" + f" ++logger.wandb.project={wandb_project}" + f" ++logger.wandb.name={prepared.expname}" + f" ++logger.wandb.group={prepared.expname}" + ) - # Append extra_arguments from stage_kwargs - stage_kwargs = config.get("stage_kwargs", {}) - if extra_args := stage_kwargs.get("extra_arguments"): - args = f"{args} {extra_args}" - console.detail("Extra arguments", extra_args) - - # Build job submission kwargs - sft_kwargs: dict[str, Any] = { - "ctx": wrap_arguments(args), - "cluster": cluster, - "expname": prepared.expname, - "backend": prepared.backend, - "output_dir": prepared.output_dir, - "hf_model": prepared.hf_checkpoint_path, - "training_data": prepared.training_data, - "num_gpus": prepared.num_gpus, - "num_nodes": prepared.num_nodes, - "dependent_jobs": prepared.dependent_jobs, - } - - if run_after: - sft_kwargs["run_after"] = run_after - - # Optional kwargs from stage_kwargs - for key in ("partition", "installation_command"): - if key in stage_kwargs: - sft_kwargs[key] = stage_kwargs[key] + extra_parts = [] + if base_args := config.get("extra_arguments"): + extra_parts.append(base_args) + if stage_args := stage_kwargs.get("extra_arguments"): + extra_parts.append(stage_args) + if extra_parts: + cmd += " " + " ".join(extra_parts) - if prepared.validation_data: - sft_kwargs["validation_data"] = prepared.validation_data + return cmd - if prepared.wandb_mode == "online" and prepared.wandb_project: - sft_kwargs["wandb_project"] = prepared.wandb_project - - sft_nemo_rl(**sft_kwargs) - console.success("SFT training job submitted") + def _submit_sft_job( + self, + prepared: PreparedNemoRLConfig, + cluster_config: dict, + config: dict[str, Any], + run_after: list[str] | None = None, + ) -> None: + """Submit SFT training job via direct add_task() + run_exp().""" + from nemo_skills.pipeline.nemo_rl.grpo import parse_kwargs + from nemo_skills.pipeline.utils.exp import add_task, get_exp, run_exp - # ======================================================================== - # Metadata & Utilities - # ======================================================================== + config_snippet, config_path = self._config_shell_snippet(prepared) + train_cmd = self._build_train_cmd( + prepared, config, config_snippet, config_path, cluster_config + ) - def _save_nemo_rl_metadata(self, prepared: PreparedNemoRLConfig) -> None: - """Save run metadata YAML for NeMo-RL format config.""" - slurm_job_id = os.environ.get("SLURM_JOB_ID") - run_id = f"job_{slurm_job_id}" if slurm_job_id else datetime.now().strftime("%Y%m%d_%H%M%S") - - metadata = { - "start_time": datetime.now().isoformat(), - "slurm_job_id": slurm_job_id, - "status": "submitted", - "format": "nemo_rl", - "preset": prepared.preset, - "backend": prepared.backend, - "run_name": prepared.run_name, - "output_dir": prepared.output_dir, - "hf_model_name": prepared.hf_model_name, - "num_nodes": prepared.num_nodes, - "num_gpus": prepared.num_gpus, - "nemo_rl_config": prepared.nemo_rl_config, - } - - output_path = self._resolve_output_path(prepared.output_dir) - try: - output_path.mkdir(parents=True, exist_ok=True) - metadata_file = output_path / f"run_metadata_{run_id}.yaml" - - with open(metadata_file, "w") as f: - f.write(f"# Run Metadata - {run_id}\n") - f.write("# Auto-generated for reproducibility (NeMo-RL format)\n\n") - yaml.dump( - metadata, f, default_flow_style=False, sort_keys=False, allow_unicode=True + stage_kwargs = config.get("stage_kwargs", {}) + partition = stage_kwargs.get("partition") + sbatch_kwargs = parse_kwargs(stage_kwargs.get("sbatch_kwargs", "")) + dependent_jobs = config.get("dependent_jobs", 0) + + with get_exp(prepared.expname, cluster_config) as exp: + prev_task = None + for job_id in range(dependent_jobs + 1): + prev_task = add_task( + exp, + cmd=train_cmd, + task_name=f"{prepared.expname}-sft-{job_id}", + log_dir=f"{prepared.output_dir}/training-logs", + container=cluster_config["containers"]["nemo-rl"], + num_gpus=prepared.num_gpus, + num_nodes=prepared.num_nodes, + cluster_config=cluster_config, + with_ray=True, + sbatch_kwargs=sbatch_kwargs, + installation_command=stage_kwargs.get("installation_command"), + partition=partition, + run_after=run_after, + task_dependencies=[prev_task] if prev_task else None, ) + run_exp(exp, cluster_config, sequential=False) - console.detail("Run metadata saved", str(metadata_file)) - except (OSError, PermissionError) as e: - console.warning(f"Could not save metadata: {e}") - - def _resolve_output_path(self, output_dir: str) -> Path: - """Resolve output path, handling /workspace vs local submission node.""" - if output_dir.startswith("/workspace/") and not Path("/workspace").exists(): - return Path(output_dir.replace("/workspace/", "./")) - return Path(output_dir) + console.success("SFT training job submitted") # ======================================================================== # Main Entry Points @@ -660,17 +670,15 @@ def execute( ) -> None: """Execute SFT training. - This is the main entry point for SFT training. It: - 1. Prepares training configuration (NeMo-RL format) - 2. Displays training job summary - 3. Submits training job to cluster - - All presets now use NeMo-RL native format (policy.*, sft.*, etc.) + Resolves cluster_config once and passes the dict to all downstream + methods (same pattern as GRPO). """ - # Prepare and submit NeMo-RL format job - prepared = self._prepare_nemo_rl_config(config, expname) + from nemo_skills.pipeline.utils import get_cluster_config + + cluster_config = get_cluster_config(cluster) + prepared = self._prepare_nemo_rl_config(config, expname, cluster_config=cluster_config) self._display_nemo_rl_summary(prepared) - self._submit_nemo_rl_job(prepared, cluster, config, run_after=run_after) + self._submit_sft_job(prepared, cluster_config, config, run_after=run_after) def validate_config(self, config: dict[str, Any]) -> None: """Validate configuration. diff --git a/nvflow/recipes/finance/stages/shared/__init__.py b/nvflow/recipes/finance/stages/shared/__init__.py index 33362ab..ad294ce 100644 --- a/nvflow/recipes/finance/stages/shared/__init__.py +++ b/nvflow/recipes/finance/stages/shared/__init__.py @@ -19,6 +19,6 @@ _current_dir = Path(__file__).parent for file in _current_dir.glob("*.py"): - if file.stem.startswith("_"): + if file.name.startswith(".") or file.stem.startswith("_"): continue importlib.import_module(f".{file.stem}", package=__package__) diff --git a/nvflow/recipes/finance/stages/shared/data_transformation.py b/nvflow/recipes/finance/stages/shared/data_transformation.py index 7f61262..b2dccb5 100644 --- a/nvflow/recipes/finance/stages/shared/data_transformation.py +++ b/nvflow/recipes/finance/stages/shared/data_transformation.py @@ -12,7 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Transform raw SDG dataset to standard training format (shared: SFT + GRPO).""" +"""Transform raw SDG dataset to standard training format (shared: SFT + GRPO). + +For GRPO workflows with ``environments``, runs per-environment: each +environment's ``raw_train_data`` is transformed and written to +``{output_dir}/{env_name}/``. + +For SFT workflows (no ``environments``), runs once with ``input_files`` +and ``output_file`` from config (SFT single-dataset mode). +""" from typing import Any @@ -38,23 +46,75 @@ def execute( expname: str, run_after: list[str] | None = None, ) -> None: - """Execute data transformation.""" - from nemo_skills.pipeline.cli import run_cmd, wrap_arguments + """Execute data transformation (per-environment when environments present).""" + environments = config.get("environments") + if environments: + self._execute_per_env(config, cluster, expname, run_after) + else: + self._execute_sft(config, cluster, expname, run_after) + + def _execute_per_env( + self, + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None = None, + ) -> None: + from nvflow.lib.rl.helpers import resolve_environments + environments = resolve_environments(config) + base_output_dir = config["output_dir"] + num_chunks = config.get("num_chunks", 1) + # Filenames are YAML-driven (default preserves existing behaviour). + input_filename = config.get("input_filename", "final_result.jsonl") + output_filename = config.get("output_filename", "final_result.jsonl") + + for env_name, env_cfg in environments.items(): + raw_data = env_cfg.get("raw_train_data") + if not raw_data: + console.warning(f"Skipping environment '{env_name}': no raw_train_data configured") + continue + + env_output_dir = f"{base_output_dir}/{env_name}" + env_output_file = f"{env_output_dir}/{output_filename}" + source_format = env_cfg.get("source_format", "separated") + reasoning_mode = env_cfg.get("reasoning_mode", "none") + + console.status(f"Transforming dataset for environment: {env_name}") + console.detail("Input", f"{raw_data}/{input_filename}") + console.detail("Output", f"{env_output_dir}/chunks/ ({num_chunks} chunks)") + console.detail("Source format", source_format) + console.detail("Reasoning mode", reasoning_mode) + console.blank() + + self._submit_transform_job( + input_files=[f"{raw_data}/{input_filename}"], + output_file=env_output_file, + output_dir=env_output_dir, + source_format=source_format, + reasoning_mode=reasoning_mode, + num_chunks=num_chunks, + config=config, + cluster=cluster, + expname=f"{expname}-{env_name}", + run_after=run_after, + ) + + def _execute_sft( + self, + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None = None, + ) -> None: + """SFT single-dataset mode.""" input_files = config["input_files"] if isinstance(input_files, str): input_files = [input_files] output_file = config["output_file"] - - # Source format - how the SDG data is structured - # Options: "separated", "think_tags", "inline" source_format = config.get("source_format", "separated") - - # Reasoning mode - how to format generation for training - # Options: "thinking", "natural", "none" reasoning_mode = config.get("reasoning_mode", "none") - num_chunks = config.get("num_chunks", 1) output_dir = config.get("output_dir", "/tmp") @@ -67,7 +127,35 @@ def execute( console.detail("Reasoning mode", reasoning_mode) console.blank() - # Build the transformation command with multiple input files + self._submit_transform_job( + input_files=input_files, + output_file=output_file, + output_dir=output_dir, + source_format=source_format, + reasoning_mode=reasoning_mode, + num_chunks=num_chunks, + config=config, + cluster=cluster, + expname=expname, + run_after=run_after, + ) + + def _submit_transform_job( + self, + *, + input_files: list[str], + output_file: str, + output_dir: str, + source_format: str, + reasoning_mode: str, + num_chunks: int, + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None, + ) -> None: + from nemo_skills.pipeline.cli import run_cmd, wrap_arguments + input_files_str = " ".join(f"'{f}'" for f in input_files) cmd = ( f"python -m nvflow.recipes.finance.utils.shared.dataset_transformer " @@ -78,11 +166,9 @@ def execute( cmd += f" --source_format {source_format}" cmd += f" --reasoning_mode {reasoning_mode}" - # Add chunking options if enabled if num_chunks > 1: cmd += f" --num_chunks {num_chunks}" - # Add filtering options if enabled filter_outliers = config.get("filter_outliers", False) if filter_outliers: cmd += " --filter_outliers" @@ -98,28 +184,44 @@ def execute( cmd += f" --reasoning_min_percentile {reasoning_min}" cmd += f" --reasoning_max_percentile {reasoning_max}" - # Submit transformation job + if config.get("deduplicate_by_uuid", False): + cmd += " --deduplicate_by_uuid" + run_cmd( ctx=wrap_arguments(cmd), cluster=cluster, - log_dir=f"{config.get('output_dir', '/tmp')}/logs", + log_dir=f"{output_dir}/logs", expname=expname, run_after=run_after, ) - # Output always goes to chunks/ directory (consistent structure regardless of num_chunks) console.success( f"Data transformation job submitted → {output_dir}/chunks/ ({num_chunks} chunks)" ) def validate_config(self, config: dict[str, Any]) -> None: """Validate that required configuration fields are present.""" - for field in ("input_files", "output_file"): - if field not in config: - raise ValueError(f"'{field}' is required in data_transformation config") - - if config.get("source_format") == "inline" and config.get("reasoning_mode") == "thinking": - raise ValueError( - "Invalid combination: source_format='inline' + reasoning_mode='thinking'. " - "Use reasoning_mode='natural' or 'none' for inline source format." - ) + if config.get("environments"): + if "output_dir" not in config: + raise ValueError("'output_dir' is required in data_transformation config") + for env_name, env_cfg in config["environments"].items(): + sf = env_cfg.get("source_format", "separated") + rm = env_cfg.get("reasoning_mode", "none") + if sf == "inline" and rm == "thinking": + raise ValueError( + f"Invalid combination in environment '{env_name}': " + f"source_format='inline' + reasoning_mode='thinking'. " + "Use reasoning_mode='natural' or 'none' for inline source format." + ) + else: + for field in ("input_files", "output_file"): + if field not in config: + raise ValueError(f"'{field}' is required in data_transformation config") + if ( + config.get("source_format") == "inline" + and config.get("reasoning_mode") == "thinking" + ): + raise ValueError( + "Invalid combination: source_format='inline' + reasoning_mode='thinking'. " + "Use reasoning_mode='natural' or 'none' for inline source format." + ) diff --git a/nvflow/recipes/finance/stages/shared/train_validation_split.py b/nvflow/recipes/finance/stages/shared/train_validation_split.py index d1a8de3..aaba485 100644 --- a/nvflow/recipes/finance/stages/shared/train_validation_split.py +++ b/nvflow/recipes/finance/stages/shared/train_validation_split.py @@ -12,7 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Split dataset into train and validation sets (shared: SFT + GRPO).""" +"""Split dataset into train and validation sets (shared: SFT + GRPO). + +For GRPO workflows with ``environments``, runs per-environment: reads +from ``{input_dir}/{env_name}/{input_filename}`` and writes to +``{output_dir}/{env_name}/``. The input filename defaults to +``final_result.jsonl`` but can be overridden via ``input_filename`` +in the stage config. + +For SFT workflows (no ``environments``), runs once with ``input_file`` +from config (SFT single-dataset mode). +""" from typing import Any @@ -39,17 +49,83 @@ def execute( expname: str, run_after: list[str] | None = None, ) -> None: - """Execute train/validation split.""" + """Execute train/validation split (per-environment when environments present).""" + environments = config.get("environments") + if environments: + self._execute_per_env(config, cluster, expname, run_after) + else: + self._execute_sft(config, cluster, expname, run_after) + + def _execute_per_env( + self, + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None = None, + ) -> None: + from nvflow.lib.rl.helpers import resolve_environments + + environments = resolve_environments(config) + base_input_dir = config["input_dir"] + base_output_dir = config["output_dir"] + + for env_name, env_cfg in environments.items(): + if not env_cfg.get("raw_train_data"): + console.warning(f"Skipping environment '{env_name}': no raw_train_data configured") + continue + input_filename = config.get("input_filename", "final_result.jsonl") + env_input_file = f"{base_input_dir}/{env_name}/{input_filename}" + env_output_dir = f"{base_output_dir}/{env_name}" + + console.status(f"Splitting dataset for environment: {env_name}") + + self._submit_split_job( + input_file=env_input_file, + output_dir=env_output_dir, + config=config, + cluster=cluster, + expname=f"{expname}-{env_name}", + run_after=run_after, + ) + + def _execute_sft( + self, + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None = None, + ) -> None: + """SFT single-dataset mode.""" + console.status("Splitting dataset into train and validation sets") + + self._submit_split_job( + input_file=config["input_file"], + output_dir=config["output_dir"], + config=config, + cluster=cluster, + expname=expname, + run_after=run_after, + ) + + def _submit_split_job( + self, + *, + input_file: str, + output_dir: str, + config: dict[str, Any], + cluster: str, + expname: str, + run_after: list[str] | None, + ) -> None: from nemo_skills.pipeline.cli import run_cmd, wrap_arguments - input_file = config["input_file"] - output_dir = config["output_dir"] val_ratio = config.get("val_ratio", 0.1) stratify_by = config.get("stratify_by", "question_type") random_seed = config.get("random_seed", 42) max_token_length = config.get("max_token_length") + sort_by = config.get("sort_by") + sort_order = config.get("sort_order", "desc") - console.status("Splitting dataset into train and validation sets") console.detail("Input file", input_file) console.detail("Output directory", output_dir) console.detail("Val ratio", f"{val_ratio:.1%}") @@ -57,9 +133,10 @@ def execute( console.detail("Random seed", str(random_seed)) if max_token_length: console.detail("Max token length", f"{max_token_length:,}") + if sort_by: + console.detail("Sort by", f"{sort_by} ({sort_order})") console.blank() - # Build the split command cmd = ( f"python -m nvflow.recipes.finance.utils.shared.dataset_splitter " f" '{input_file}' " @@ -73,6 +150,8 @@ def execute( cmd += f" --max_token_length {max_token_length}" if config.get("keep_all_fields", False): cmd += " --keep_all_fields" + if sort_by: + cmd += f" --sort_by '{sort_by}' --sort_order {sort_order}" run_cmd( ctx=wrap_arguments(cmd), @@ -83,22 +162,24 @@ def execute( ) console.success("Split job submitted") - console.detail("→ Output dir", output_dir) - if val_ratio > 0: - console.detail("→ Train file", f"{output_dir}/train.jsonl") - console.detail("→ Val file", f"{output_dir}/val.jsonl") - else: - console.detail("→ Train file", f"{output_dir}/train.jsonl (all data)") + console.detail("-> Output dir", output_dir) + console.detail("-> Train file", f"{output_dir}/train.jsonl") + console.detail("-> Val file", f"{output_dir}/val.jsonl") def validate_config(self, config: dict[str, Any]) -> None: """Validate that required configuration fields are present.""" - required = ["input_file", "output_dir"] - for field in required: - if field not in config: - raise ValueError(f"'{field}' is required in config") + if config.get("environments"): + for field in ("input_dir", "output_dir"): + if field not in config: + raise ValueError(f"'{field}' is required in train_validation_split config") + else: + for field in ("input_file", "output_dir"): + if field not in config: + raise ValueError(f"'{field}' is required in train_validation_split config") - # Validate val_ratio if provided if "val_ratio" in config: val_ratio = config["val_ratio"] - if not (0 < val_ratio < 1): - raise ValueError(f"'val_ratio' must be between 0 and 1, got {val_ratio}") + if not (0 <= val_ratio <= 1): + raise ValueError( + f"'val_ratio' must be between 0 and 1 (inclusive), got {val_ratio}" + ) diff --git a/nvflow/recipes/finance/tests/test_prepare_for_sft.py b/nvflow/recipes/finance/tests/test_prepare_for_sft.py index 218facc..cdba6f3 100644 --- a/nvflow/recipes/finance/tests/test_prepare_for_sft.py +++ b/nvflow/recipes/finance/tests/test_prepare_for_sft.py @@ -63,7 +63,6 @@ def test_validate_config_with_optional_fields(self): "input_dir": "/path/to/input", "output_dir": "/path/to/output", "prepare_data_kwargs": {"ctx_args": "++prompt_config=test"}, - "stage_kwargs": {"partition": "cpu"}, } # Should not raise any exception stage.validate_config(config) diff --git a/nvflow/recipes/finance/utils/evaluation/checkpoint_converter.py b/nvflow/recipes/finance/utils/evaluation/checkpoint_converter.py index 59a74ad..6a961f9 100644 --- a/nvflow/recipes/finance/utils/evaluation/checkpoint_converter.py +++ b/nvflow/recipes/finance/utils/evaluation/checkpoint_converter.py @@ -40,6 +40,44 @@ # ============================================================================ +_BASE_MODEL_FILES = [ + # Tokenizer files (Bridge re-serializes via transformers, corrupting them) + "tokenizer_config.json", + "tokenizer.json", + "chat_template.jinja", + "special_tokens_map.json", + "vocab.json", + "merges.txt", + "tokenizer.model", + "added_tokens.json", + # Model config files (Bridge rewrites with newer transformers format, + # which can change field names/structure that vLLM depends on) + "config.json", + "generation_config.json", +] + + +def _copy_from_base(base_model_path: Path, hf_output_path: Path) -> None: + """Copy config and tokenizer files from the base model to the converted checkpoint. + + Megatron Bridge re-serializes these files via transformers, which can + corrupt them (e.g. renaming fields, restructuring rope config). + Fine-tuning (SFT/GRPO) does not modify the architecture or tokenizer, + so the base model's files are canonical. + """ + import shutil + + copied = [] + for name in _BASE_MODEL_FILES: + src = base_model_path / name + if src.exists(): + shutil.copy2(src, hf_output_path / name) + copied.append(name) + + if copied: + logger.info(f"Copied from base model: {', '.join(copied)}") + + def convert_checkpoint( megatron_path: str | Path, hf_output_path: str | Path, @@ -110,6 +148,8 @@ def convert_checkpoint( if not (hf_output_path / "config.json").exists(): raise RuntimeError(f"Conversion failed - {hf_output_path}/config.json not found") + _copy_from_base(Path(model_name), hf_output_path) + logger.info("") logger.info(f"✓ Conversion complete: {hf_output_path}") logger.info("=" * 60) @@ -126,7 +166,7 @@ def get_hf_output_paths(run_path: str | Path, step: int) -> tuple[Path, Path]: Derive HF model and log paths for a given training run and step. Given a training run at: - .../model-qwen3-14b-32n-tp4-pp1-cp8-seq48k + .../model-qwen3-14b-256g-tp4-pp1-cp8-seq48k Returns paths for step 5000: HF model: .../hf_models/step_5000 @@ -183,14 +223,19 @@ def build_dcp_conversion_script( step: int, hf_output_path: str | Path, ) -> str: - """Build a bash script that converts a DCP (FSDP) checkpoint to HF format. + """Build a bash script that converts a DTensor checkpoint to HF format. + + Supports both checkpoint formats: + - **v1 (DCP)**: ``.metadata`` file → calls ``convert_dcp_to_hf.py`` + - **v2 (safetensors)**: ``shard-*.safetensors`` files → calls ``offline_hf_consolidation.py`` The script runs ON THE CLUSTER. It resolves the run subdirectory under - checkpoint_path (flat or GRPO layout), then calls NeMo-RL's converter. + checkpoint_path (flat or GRPO layout), auto-detects the format, then + calls the appropriate converter. Args: - checkpoint_path: Parent dir (e.g. .../step-7-training) — may contain - a nested run dir like grpo-qwen3-4b-.../checkpoints/step_N/... + checkpoint_path: Parent dir (e.g. .../step-8-training/equivalence_llm_judge) + — may contain a nested run dir like grpo-qwen3-4b-.../checkpoints/step_N/... step: Checkpoint step number hf_output_path: Where to write HF model (known at submit time) """ @@ -220,23 +265,52 @@ def build_dcp_conversion_script( fi done if [ -z "$RUN_PATH" ]; then - echo "ERROR: No DCP checkpoint for $STEP_NAME under $CKPT_ROOT" >&2 + echo "ERROR: No checkpoint for $STEP_NAME under $CKPT_ROOT" >&2 exit 1 fi fi STEP_DIR="$RUN_PATH/checkpoints/$STEP_NAME" +WEIGHTS_DIR="$STEP_DIR/policy/weights" +MODEL_DIR="$WEIGHTS_DIR/model" echo "Resolved run path: $RUN_PATH" -echo "Converting DCP checkpoint: $STEP_DIR -> $HF_OUTPUT" -cd /opt/NeMo-RL -uv run examples/converters/convert_dcp_to_hf.py \\ - --config="$STEP_DIR/config.yaml" \\ - --dcp-ckpt-path="$STEP_DIR/policy/weights" \\ - --hf-ckpt-path="$HF_OUTPUT" +mkdir -p "$HF_OUTPUT" + +# Auto-detect format: v2 safetensors (shard-*.safetensors) or v1 DCP (.metadata) +if ls "$MODEL_DIR"/shard-*.safetensors 1>/dev/null 2>&1; then + echo "Detected DTensor v2 (safetensors) checkpoint" + echo "Consolidating: $MODEL_DIR -> $HF_OUTPUT" + + # Recreate .hf_metadata if missing (offline_hf_consolidation.py deletes it after use) + if [ ! -d "$MODEL_DIR/.hf_metadata" ]; then + echo "Recreating .hf_metadata from base model index..." + PYTHONPATH=/workspace python3 -m nvflow.recipes.finance.utils.evaluation.checkpoint_converter \\ + --recreate-hf-metadata "$MODEL_DIR" "$STEP_DIR/config.yaml" + fi + + cd /opt/NeMo-RL + export UV_PROJECT=/opt/NeMo-RL + uv run --extra automodel python /opt/NeMo-RL/3rdparty/Automodel-workspace/Automodel/tools/offline_hf_consolidation.py \\ + --model-name unused \\ + --input-dir "$MODEL_DIR" \\ + --output-dir "$HF_OUTPUT" -rsync -ahP "$STEP_DIR/policy/tokenizer/" "$HF_OUTPUT/" -echo "DCP conversion complete: $HF_OUTPUT" + rsync -ahP "$STEP_DIR/policy/tokenizer/" "$HF_OUTPUT/" + echo "Safetensors consolidation complete: $HF_OUTPUT" +else + echo "Detected DTensor v1 (DCP) checkpoint" + echo "Converting: $STEP_DIR -> $HF_OUTPUT" + + cd /opt/NeMo-RL + uv run examples/converters/convert_dcp_to_hf.py \\ + --config="$STEP_DIR/config.yaml" \\ + --dcp-ckpt-path="$WEIGHTS_DIR" \\ + --hf-ckpt-path="$HF_OUTPUT" + + rsync -ahP "$STEP_DIR/policy/tokenizer/" "$HF_OUTPUT/" + echo "DCP conversion complete: $HF_OUTPUT" +fi """ return script @@ -246,33 +320,130 @@ def build_dcp_conversion_script( # ============================================================================ +def recreate_hf_metadata(model_dir: str, training_config: str) -> None: + """Recreate .hf_metadata from the base HF model index. + + offline_hf_consolidation.py destructively removes .hf_metadata after use. + This function rebuilds it from the base model so consolidation can be + re-run on the same checkpoint. + + Args: + model_dir: Directory containing shard-*.safetensors + training_config: Path to training config.yaml (to find base model path) + """ + import json + import shutil + + import yaml + + cfg = yaml.safe_load(open(training_config)) + base_model = cfg["policy"]["model_name"] + base_path = Path(base_model) + + index_file = base_path / "model.safetensors.index.json" + if not index_file.exists(): + raise FileNotFoundError(f"Base model index not found: {index_file}") + + index = json.load(open(index_file)) + weight_map = index["weight_map"] + + file_list = sorted(set(weight_map.values())) + file_to_idx = {f: i + 1 for i, f in enumerate(file_list)} + fqn_mapping = {k: file_to_idx[v] for k, v in weight_map.items()} + + hf_meta_dir = Path(model_dir) / ".hf_metadata" + hf_meta_dir.mkdir(parents=True, exist_ok=True) + + with open(hf_meta_dir / "fqn_to_file_index_mapping.json", "w") as f: + json.dump(fqn_mapping, f, indent=2, sort_keys=True) + logger.info(f"Wrote fqn_to_file_index_mapping.json ({len(fqn_mapping)} tensors)") + + for name in ("config.json", "generation_config.json"): + src = base_path / name + if src.exists(): + shutil.copy2(str(src), str(hf_meta_dir / name)) + + for name in ( + "tokenizer_config.json", + "tokenizer.json", + "vocab.json", + "merges.txt", + "chat_template.jinja", + "special_tokens_map.json", + "tokenizer.model", + ): + src = base_path / name + if src.exists(): + shutil.copy2(str(src), str(hf_meta_dir / name)) + + logger.info(f"Recreated .hf_metadata from {base_model}") + + +def patch_torch_dtype(training_config_path: str, hf_config_path: str) -> None: + """Patch torch_dtype in HF config.json from training config. + + WORKAROUND: nemo_automodel's consolidation saves fp32 master weights + and does not set torch_dtype. Without this, HF/vLLM defaults to fp32. + + Args: + training_config_path: Path to training config.yaml (has policy.precision) + hf_config_path: Path to HF config.json to patch + """ + import json + + import yaml + + cfg = yaml.safe_load(open(training_config_path)) + hf_cfg = json.load(open(hf_config_path)) + hf_cfg["torch_dtype"] = cfg["policy"]["precision"] + json.dump(hf_cfg, open(hf_config_path, "w"), indent=2) + logger.info(f"Patched torch_dtype: {hf_cfg['torch_dtype']}") + + def main(): parser = argparse.ArgumentParser( description="Convert Megatron checkpoint to HuggingFace format" ) parser.add_argument( "--megatron-path", - required=True, help="Path to Megatron checkpoint (e.g., .../checkpoints/step_5000)", ) parser.add_argument( "--hf-output-path", - required=True, help="Where to save HF model (e.g., .../hf_models/step_5000)", ) parser.add_argument( "--model-name", - required=True, help="HF model name for tokenizer/architecture (e.g., Qwen/Qwen3-14B)", ) + parser.add_argument( + "--patch-dtype", + nargs=2, + metavar=("CONFIG_YAML", "HF_CONFIG_JSON"), + help="Patch torch_dtype in HF config.json from training config.yaml", + ) + parser.add_argument( + "--recreate-hf-metadata", + nargs=2, + metavar=("MODEL_DIR", "CONFIG_YAML"), + help="Recreate .hf_metadata from base model index (for re-running consolidation)", + ) args = parser.parse_args() try: - convert_checkpoint( - megatron_path=args.megatron_path, - hf_output_path=args.hf_output_path, - model_name=args.model_name, - ) + if args.recreate_hf_metadata: + recreate_hf_metadata(args.recreate_hf_metadata[0], args.recreate_hf_metadata[1]) + elif args.patch_dtype: + patch_torch_dtype(args.patch_dtype[0], args.patch_dtype[1]) + elif args.megatron_path: + convert_checkpoint( + megatron_path=args.megatron_path, + hf_output_path=args.hf_output_path, + model_name=args.model_name, + ) + else: + parser.print_help() + return 1 return 0 except (FileNotFoundError, RuntimeError) as e: logger.error(f"✗ ERROR: {e}") diff --git a/nvflow/recipes/finance/utils/rl/aggregate_seeds.py b/nvflow/recipes/finance/utils/rl/aggregate_seeds.py index 528ec8e..c1da62d 100644 --- a/nvflow/recipes/finance/utils/rl/aggregate_seeds.py +++ b/nvflow/recipes/finance/utils/rl/aggregate_seeds.py @@ -13,11 +13,21 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Aggregate rollouts across seeds and compute pass@k metrics. +"""Aggregate rollouts across seeds and compute difficulty metrics. Reads merged rollout files (``output-rs*.jsonl``) from a ``collect_rollouts`` output directory, groups rows by ``uuid`` across -seeds, and computes the unbiased pass@k estimator for every k from 1 to num_seeds. +seeds, and computes: + +- **avg_reward**: mean reward across seeds per question. +- **reward_std**: sample standard deviation of rewards per question. + Used by ``filter_training_data`` to identify learnable questions + (those with non-zero reward variance provide GRPO gradient signal). +- **global_max**: highest reward observed across all data, used as the + ``c`` threshold for pass@k (scale-independent). +- **pass@k**: unbiased combinatorial estimator (binary — only + reward == global_max counts as correct). Standard metric for + reporting. Standalone script that runs inside the Slurm container with python3. @@ -27,7 +37,7 @@ Produces: /summary.txt -- human-readable report /metrics.json -- machine-readable metrics - /difficulty.jsonl -- per-question pass rates and pass@k + /difficulty.jsonl -- per-question reward stats and pass@k """ import json @@ -55,7 +65,11 @@ def pass_at_k(n: int, c: int, k: int) -> float: return 1.0 - math.prod(1.0 - k / i for i in range(n - c + 1, n + 1)) -def aggregate(rollout_dir: str, output_dir: str) -> None: +def aggregate( + rollout_dir: str, + output_dir: str, + output_filename: str = "difficulty.jsonl", +) -> None: rollout_path = Path(rollout_dir) out = Path(output_dir) out.mkdir(parents=True, exist_ok=True) @@ -99,16 +113,36 @@ def aggregate(rollout_dir: str, output_dir: str) -> None: k_values = list(range(1, num_seeds + 1)) records: list[dict] = [] + global_max = max(r.get("reward", 0.0) for rows in by_uuid.values() for r in rows) + + if global_max <= 0: + print( + "[nvflow] WARNING: aggregate_seeds observed no positive rewards " + f"(global_max={global_max}). Reporting pass@k=0 by convention; " + "the underlying reward / verifier / judge is likely misconfigured.", + file=sys.stderr, + ) + for uid, rows in by_uuid.items(): n = len(rows) - c = sum(1 for r in rows if r.get("reward", 0.0) == 1.0) + rewards = [r.get("reward", 0.0) for r in rows] + c = 0 if global_max <= 0 else sum(1 for rw in rewards if rw == global_max) + avg_reward = sum(rewards) / n if n > 0 else 0.0 + reward_std = ( + (sum((rw - avg_reward) ** 2 for rw in rewards) / (n - 1)) ** 0.5 if n > 1 else 0.0 + ) + reward_min = min(rewards) + reward_max = max(rewards) question_type = rows[0].get("question_type", "unknown") rec: dict = { "uuid": uid, "n": n, "c": c, - "pass_rate": c / n if n > 0 else 0.0, + "avg_reward": avg_reward, + "reward_std": reward_std, + "reward_min": reward_min, + "reward_max": reward_max, "question_type": question_type, "question": rows[0].get("question", ""), "expected_answer": rows[0].get("expected_answer", ""), @@ -123,6 +157,7 @@ def aggregate(rollout_dir: str, output_dir: str) -> None: "num_seeds": num_seeds, "num_questions": num_questions, "total_rows": total_rows, + "global_max_reward": global_max, } for k in k_values: @@ -149,16 +184,18 @@ def aggregate(rollout_dir: str, output_dir: str) -> None: metrics["by_question_type"] = type_metrics - # Difficulty distribution. - pass_rates = [r["pass_rate"] for r in records] - avg_pass_rate = sum(pass_rates) / num_questions + # Difficulty distribution (reward_std buckets). + reward_stds = [r["reward_std"] for r in records] + avg_reward_val = sum(r["avg_reward"] for r in records) / num_questions + avg_reward_std = sum(reward_stds) / num_questions buckets: Counter = Counter() - for p in pass_rates: - buckets[p] += 1 + for s in reward_stds: + buckets[round(s, 4)] += 1 metrics["difficulty"] = { - "avg_pass_rate": avg_pass_rate, + "avg_reward": avg_reward_val, + "avg_reward_std": avg_reward_std, "distribution": {f"{k:.4f}": v for k, v in sorted(buckets.items())}, } @@ -166,15 +203,16 @@ def aggregate(rollout_dir: str, output_dir: str) -> None: json.dump(metrics, f, indent=2) logger.info("Metrics -> %s", out / "metrics.json") - records_sorted = sorted(records, key=lambda r: r["pass_rate"]) - with open(out / "difficulty.jsonl", "w") as f: + records_sorted = sorted(records, key=lambda r: r["reward_std"], reverse=True) + difficulty_path = out / output_filename + with open(difficulty_path, "w") as f: for r in records_sorted: f.write(json.dumps(r) + "\n") - logger.info("Difficulty -> %s", out / "difficulty.jsonl") + logger.info("Difficulty -> %s", difficulty_path) # Human-readable summary. - sorted_rates = sorted(buckets.keys()) - mixed = sum(1 for p in pass_rates if 0.0 < p < 1.0) + sorted_std_keys = sorted(buckets.keys()) + has_signal = sum(1 for s in reward_stds if s > 0) lines = [ "CROSS-SEED AGGREGATION", @@ -182,8 +220,10 @@ def aggregate(rollout_dir: str, output_dir: str) -> None: f"Seeds: {num_seeds}", f"Questions (uuid): {num_questions}", f"Total rows: {total_rows}", - f"Avg pass rate: {avg_pass_rate:.1%}", - f"Mixed (00):{has_signal} ({has_signal / num_questions:.1%})", "", ] @@ -213,33 +253,31 @@ def aggregate(rollout_dir: str, output_dir: str) -> None: lines.append(overall) lines.append("") - # Pass rate histogram. - lines.append("Difficulty distribution:") - for rate in sorted_rates: - count = buckets[rate] + # Reward std histogram. + lines.append("Reward std distribution (per-question sample std across seeds):") + for std_val in sorted_std_keys: + count = buckets[std_val] pct = count / num_questions * 100 bar = "#" * int(pct / 100 * 40) - correct = round(rate * num_seeds) - label = f"{correct}/{num_seeds}" - lines.append(f" {label:>5s} ({rate:5.1%}): {count:5d} ({pct:5.1f}%) {bar}") + lines.append(f" {std_val:6.4f}: {count:5d} ({pct:5.1f}%) {bar}") lines.append("") # Per-type difficulty breakdown. - rate_labels = [f"{round(r * num_seeds)}/{num_seeds}" for r in sorted_rates] + std_labels = [f"{s:.4f}" for s in sorted_std_keys] header = f" {'Type':<15} {'Total':>5}" - for lbl in rate_labels: - header += f" {lbl:>5}" + for lbl in std_labels: + header += f" {lbl:>7}" lines.append("By question type:") lines.append(header) - lines.append(" " + "-" * (22 + 6 * len(rate_labels))) + lines.append(" " + "-" * (22 + 8 * len(std_labels))) for qt in sorted(by_type.keys()): qt_records = by_type[qt] qt_buckets: Counter = Counter() for r in qt_records: - qt_buckets[r["pass_rate"]] += 1 + qt_buckets[round(r["reward_std"], 4)] += 1 row = f" {qt:<15} {len(qt_records):>5}" - for rate in sorted_rates: - row += f" {qt_buckets.get(rate, 0):>5}" + for std_val in sorted_std_keys: + row += f" {qt_buckets.get(std_val, 0):>7}" lines.append(row) lines.append("") lines.append("=" * 60) @@ -250,7 +288,17 @@ def aggregate(rollout_dir: str, output_dir: str) -> None: if __name__ == "__main__": - if len(sys.argv) != 3: - logger.error("Usage: python aggregate_seeds.py ") - sys.exit(1) - aggregate(sys.argv[1], sys.argv[2]) + import argparse + + parser = argparse.ArgumentParser( + description="Aggregate rollouts across seeds and compute difficulty metrics" + ) + parser.add_argument("rollout_dir", help="Directory containing output-rs*.jsonl rollout files.") + parser.add_argument("output_dir", help="Directory to write aggregated outputs.") + parser.add_argument( + "--output_filename", + default="difficulty.jsonl", + help="Filename for the per-question reward stats JSONL (default: difficulty.jsonl).", + ) + args = parser.parse_args() + aggregate(args.rollout_dir, args.output_dir, output_filename=args.output_filename) diff --git a/nvflow/recipes/finance/utils/rl/analyze_rollouts.py b/nvflow/recipes/finance/utils/rl/analyze_rollouts.py index 13c7dcb..27f3bb0 100644 --- a/nvflow/recipes/finance/utils/rl/analyze_rollouts.py +++ b/nvflow/recipes/finance/utils/rl/analyze_rollouts.py @@ -18,33 +18,485 @@ Standalone script that runs inside the Slurm container with python3. Usage: - python analyze_rollouts.py [title] + python analyze_rollouts.py [title] [judge_schema] Produces: - /summary.txt -- human-readable analysis - /correct.jsonl -- samples with reward == 1.0 - /incorrect.jsonl -- samples with reward == 0.0 - /partial.jsonl -- samples with 0 < reward < 1 - /judge_failed.jsonl -- samples with no judge evaluations + /summary.txt -- human-readable analysis + /step_metrics.json -- machine-readable token & step metrics + /best.jsonl -- samples with reward == max_reward + /worst.jsonl -- samples with reward == min_reward + /intermediate.jsonl -- samples with min_reward < reward < max_reward + /judge_failed.jsonl -- samples where judge produced no result + +Judge output schema is auto-detected from the data unless explicitly +provided via the ``judge_schema`` argument. To add a new schema, add an +entry to :data:`_FIELD_TO_SCHEMA` and a handler to :data:`_SCHEMA_HANDLERS`. Cross-seed difficulty analysis (pass@k, per-question pass rates) is handled separately by aggregate_seeds.py. """ import json +import statistics import sys from collections import Counter +from collections.abc import Callable from pathlib import Path from nvflow.utils import setup_logger logger = setup_logger(__name__) +# --------------------------------------------------------------------------- +# Judge schema handlers +# --------------------------------------------------------------------------- +# Each handler: (rollouts) -> (verdict_counts, judge_failed) + +JudgeHandler = Callable[[list[dict]], tuple[Counter, list[dict]]] + + +def _judge_evaluations(rollouts: list[dict]) -> tuple[Counter, list[dict]]: + """``judge_evaluations`` list with ``verdict_label`` (equivalence_llm_judge).""" + verdict_counts: Counter = Counter() + judge_failed: list[dict] = [] + for r in rollouts: + evals = r.get("judge_evaluations", []) + if not evals: + judge_failed.append(r) + for ev in evals: + verdict_counts[ev.get("verdict_label", "UNKNOWN")] += 1 + return verdict_counts, judge_failed + + +def _judge_rating(rollouts: list[dict]) -> tuple[Counter, list[dict]]: + """``judge_rating`` numeric field (finance_sec_search).""" + verdict_counts: Counter = Counter() + judge_failed: list[dict] = [] + for r in rollouts: + rating = r.get("judge_rating") + if rating is None: + judge_failed.append(r) + else: + verdict_counts[f"rating={rating}"] += 1 + return verdict_counts, judge_failed + + +def _judge_exact_match(rollouts: list[dict]) -> tuple[Counter, list[dict]]: + """``extracted_answer`` vs ``expected_answer`` (mcqa -- no external judge).""" + verdict_counts: Counter = Counter() + for r in rollouts: + matched = r.get("extracted_answer") == r.get("expected_answer") + verdict_counts["match" if matched else "mismatch"] += 1 + return verdict_counts, [] + + +_SCHEMA_HANDLERS: dict[str, JudgeHandler] = { + "evaluations": _judge_evaluations, + "rating": _judge_rating, + "exact_match": _judge_exact_match, +} + +_FIELD_TO_SCHEMA = [ + ("judge_evaluations", "evaluations"), + ("judge_rating", "rating"), + ("extracted_answer", "exact_match"), +] + +_NO_JUDGE_SCHEMAS = {"exact_match"} + + +def _detect_judge_schema(rollouts: list[dict]) -> str | None: + """Auto-detect judge output schema by scanning the first few rollouts. + + Returns a key from :data:`_SCHEMA_HANDLERS`, or ``None`` if no + recognised judge fields are found (reward-only data). + """ + sample = rollouts[: min(10, len(rollouts))] + for field, schema in _FIELD_TO_SCHEMA: + if any(r.get(field) is not None for r in sample): + return schema + return None + + +# --------------------------------------------------------------------------- +# Token & step metrics helpers +# --------------------------------------------------------------------------- + +_ERROR_SUBSTRINGS = ("error", "failed", "timed out") + + +def _dist(values: list[int | float]) -> dict[str, int | float]: + """Return min/max/mean/median for a list of numeric values.""" + if not values: + return {"min": 0, "max": 0, "mean": 0.0, "median": 0.0} + return { + "min": min(values), + "max": max(values), + "mean": round(statistics.mean(values), 1), + "median": round(statistics.median(values), 1), + } + + +def _extract_token_metrics( + rollouts: list[dict], *, max_reward: float, min_reward: float +) -> dict | None: + """Extract token usage metrics from ``response.usage`` (all environments). + + Returns ``None`` if no rollouts have usage data. + """ + input_toks: list[int] = [] + output_toks: list[int] = [] + total_toks: list[int] = [] + missing = 0 + + for r in rollouts: + usage = (r.get("response") or {}).get("usage") + if not usage: + missing += 1 + continue + input_toks.append(usage.get("input_tokens", 0)) + output_toks.append(usage.get("output_tokens", 0)) + total_toks.append(usage.get("total_tokens", 0)) + + if not input_toks: + return None + + metrics: dict = { + "input_tokens_per_rollout": _dist(input_toks), + "output_tokens_per_rollout": _dist(output_toks), + "total_tokens_per_rollout": _dist(total_toks), + "missing_usage": missing, + } + + _reward_groups: list[tuple[str, Callable[[float], bool]]] = [ + ("best", lambda rw: rw == max_reward), + ("worst", lambda rw: rw == min_reward), + ("intermediate", lambda rw: min_reward < rw < max_reward), + ] + + by_reward: dict[str, dict] = {} + for label, match in _reward_groups: + sub_in: list[int] = [] + sub_out: list[int] = [] + sub_tot: list[int] = [] + for r in rollouts: + usage = (r.get("response") or {}).get("usage") + if not usage or not match(r.get("reward", 0.0)): + continue + sub_in.append(usage.get("input_tokens", 0)) + sub_out.append(usage.get("output_tokens", 0)) + sub_tot.append(usage.get("total_tokens", 0)) + if sub_in: + by_reward[label] = { + "count": len(sub_in), + "input_tokens": _dist(sub_in), + "output_tokens": _dist(sub_out), + "total_tokens": _dist(sub_tot), + } + metrics["by_reward"] = by_reward + return metrics + + +def _is_tool_error(output_text: str) -> bool: + """Best-effort check whether a function_call_output indicates an error.""" + lower = output_text.lower() + return any(kw in lower for kw in _ERROR_SUBSTRINGS) + + +def _extract_step_metrics( + rollouts: list[dict], *, max_reward: float, min_reward: float +) -> dict | None: + """Extract multi-step agent metrics from ``response.output``. + + Returns ``None`` when no rollouts contain tool calls (single-step env). + """ + per_rollout: list[dict] = [] + + for r in rollouts: + output = (r.get("response") or {}).get("output") or [] + items = [item for item in output if isinstance(item, dict)] + if not items: + continue + + tool_calls = [i for i in items if i.get("type") == "function_call"] + tool_outputs = [i for i in items if i.get("type") == "function_call_output"] + tool_errors = sum(1 for i in tool_outputs if _is_tool_error(i.get("output", ""))) + + last_type = items[-1].get("type", "unknown") + if last_type == "message": + outcome = "Completed" + elif last_type in ("function_call_output", "function_call"): + outcome = "Truncated (mid-tool)" + elif last_type == "reasoning": + outcome = "Truncated (mid-reasoning)" + else: + outcome = "Completed" + + per_rollout.append( + { + "steps": len(items), + "tool_calls": len(tool_calls), + "tool_names": [tc.get("name", "unknown") for tc in tool_calls], + "tool_errors": tool_errors, + "tool_outputs": len(tool_outputs), + "outcome": outcome, + "last_type": last_type, + "reward": r.get("reward", 0.0), + "incomplete_details": (r.get("response") or {}).get("incomplete_details"), + } + ) + + if not per_rollout: + return None + + tc_counts = [p["tool_calls"] for p in per_rollout] + if max(tc_counts) == 0: + return None + + step_counts = [p["steps"] for p in per_rollout] + total_tool_outputs = sum(p["tool_outputs"] for p in per_rollout) + total_tool_errors = sum(p["tool_errors"] for p in per_rollout) + + tool_names: Counter = Counter() + step_types: Counter = Counter() + error_messages: Counter = Counter() + for r in rollouts: + for item in (r.get("response") or {}).get("output") or []: + if isinstance(item, dict): + t = item.get("type", "unknown") + step_types[t] += 1 + if t == "function_call": + tool_names[item.get("name", "unknown")] += 1 + elif t == "function_call_output": + out_text = item.get("output", "") + if _is_tool_error(out_text): + snippet = out_text[:120].replace("\n", " ").strip() + error_messages[snippet] += 1 + + outcome_counts = Counter(p["outcome"] for p in per_rollout) + + max_tc_observed = max(tc_counts) + truncation_reasons: Counter = Counter() + for p in per_rollout: + if p["outcome"].startswith("Truncated"): + inc = p["incomplete_details"] + if isinstance(inc, dict) and inc.get("reason"): + truncation_reasons[inc["reason"]] += 1 + elif p["tool_calls"] == max_tc_observed and max_tc_observed > 1: + truncation_reasons["tool call ceiling (inferred)"] += 1 + elif p["last_type"] == "reasoning": + truncation_reasons["max_output_tokens (inferred)"] += 1 + else: + truncation_reasons["unknown"] += 1 + + metrics: dict = { + "total_rollouts": len(per_rollout), + "steps_per_rollout": _dist(step_counts), + "tool_calls_per_rollout": _dist(tc_counts), + "tool_name_distribution": dict(tool_names.most_common()), + "tool_error_rate": { + "errors": total_tool_errors, + "total": total_tool_outputs, + "rate": round(total_tool_errors / total_tool_outputs, 4) if total_tool_outputs else 0.0, + }, + "step_type_distribution": dict(step_types.most_common()), + "outcome_distribution": dict(outcome_counts.most_common()), + "truncation_details": dict(truncation_reasons.most_common()), + "top_tool_errors": [ + {"message": msg, "count": cnt} for msg, cnt in error_messages.most_common(10) + ], + } + + by_reward: dict[str, dict] = {} + for label, reward_val in [("best", max_reward), ("worst", min_reward)]: + subset = [p for p in per_rollout if p["reward"] == reward_val] + if not subset: + continue + sub_tc = [p["tool_calls"] for p in subset] + sub_steps = [p["steps"] for p in subset] + sub_errors = sum(p["tool_errors"] for p in subset) + sub_outputs = sum(p["tool_outputs"] for p in subset) + sub_outcomes = Counter(p["outcome"] for p in subset) + sub_tool_names: Counter = Counter() + for p in subset: + sub_tool_names.update(p["tool_names"]) + by_reward[label] = { + "count": len(subset), + "tool_calls_per_rollout": _dist(sub_tc), + "steps_per_rollout": _dist(sub_steps), + "tool_error_rate": round(sub_errors / sub_outputs, 4) if sub_outputs else 0.0, + "outcome_distribution": dict(sub_outcomes.most_common()), + "tool_name_distribution": dict(sub_tool_names.most_common()), + } + + intermediate_count = sum(1 for p in per_rollout if min_reward < p["reward"] < max_reward) + if intermediate_count: + by_reward["intermediate_excluded"] = intermediate_count + + metrics["by_reward"] = by_reward + return metrics + + +# --------------------------------------------------------------------------- +# Summary formatting helpers +# --------------------------------------------------------------------------- + + +def _fmt_dist(d: dict, fmt: str = ",d") -> str: + """Format a dist dict as ``min=X max=Y mean=Z median=W``.""" + if fmt == ",d": + return ( + f"min={d['min']:,d} max={d['max']:,d} " + f"mean={int(d['mean']):,d} median={int(d['median']):,d}" + ) + return f"min={d['min']:.1f} max={d['max']:.1f} mean={d['mean']:.1f} median={d['median']:.1f}" + + +def _format_token_section(tm: dict) -> list[str]: + """Format the unconditional token usage summary section.""" + lines = [ + "", + "Token Usage:", + f" Input tokens: {_fmt_dist(tm['input_tokens_per_rollout'])}", + f" Output tokens: {_fmt_dist(tm['output_tokens_per_rollout'])}", + f" Total tokens: {_fmt_dist(tm['total_tokens_per_rollout'])}", + ] + if tm.get("missing_usage"): + lines.append(f" (missing usage data for {tm['missing_usage']} rollouts)") + + by_rw = tm.get("by_reward", {}) + if by_rw: + header_parts = [] + col_data: list[tuple[str, dict]] = [] + for label in ("best", "worst", "intermediate"): + if label in by_rw: + header_parts.append(f"{label.capitalize():>20s}") + col_data.append((label.capitalize(), by_rw[label])) + + if col_data: + lines.append("") + lines.append(" By reward outcome:") + lines.append(f" {'':25s}{''.join(header_parts)}") + counts_row = "".join(f"{d['count']:>20,d}" for _, d in col_data) + lines.append(f" {'Samples:':25s}{counts_row}") + for metric_key, metric_label in [ + ("input_tokens", "Input tokens (mean)"), + ("output_tokens", "Output tokens (mean)"), + ("total_tokens", "Total tokens (mean)"), + ]: + vals = "".join(f"{int(d[metric_key]['mean']):>20,d}" for _, d in col_data) + lines.append(f" {metric_label + ':':25s}{vals}") + + lines.append("") + return lines + + +def _format_step_section(sm: dict) -> list[str]: + """Format the multi-step agent metrics summary section.""" + total = sm["total_rollouts"] + err = sm["tool_error_rate"] + lines = [ + "Agent Step Metrics (multi-step):", + f" Steps/rollout: {_fmt_dist(sm['steps_per_rollout'])}", + f" Tool calls/rollout: {_fmt_dist(sm['tool_calls_per_rollout'])}", + f" Tool error rate: {err['rate']:.1%} ({err['errors']}/{err['total']})", + "", + ] + + lines.append(" Outcome:") + for outcome in ("Completed", "Truncated (mid-tool)", "Truncated (mid-reasoning)"): + count = sm["outcome_distribution"].get(outcome, 0) + lines.append(f" {outcome + ':':30s}{count:5d} ({count / total * 100:5.1f}%)") + lines.append("") + + trunc = sm.get("truncation_details", {}) + if trunc: + lines.append(" Truncation reasons:") + for reason, count in sorted(trunc.items(), key=lambda x: -x[1]): + lines.append(f" {reason + ':':38s}{count:5d}") + lines.append("") + + top_errors = sm.get("top_tool_errors", []) + if top_errors: + lines.append(" Top tool errors:") + for entry in top_errors: + lines.append(f" [{entry['count']:4d}x] {entry['message']}") + lines.append("") + + total_tc = sum(sm["tool_name_distribution"].values()) + lines.append(" Tool call distribution:") + for name, count in sm["tool_name_distribution"].items(): + lines.append(f" {name + ':':34s}{count:5d} ({count / total_tc * 100:5.1f}%)") + lines.append("") + + total_steps = sum(sm["step_type_distribution"].values()) + lines.append(" Step type distribution:") + for stype, count in sm["step_type_distribution"].items(): + lines.append(f" {stype + ':':30s}{count:5d} ({count / total_steps * 100:5.1f}%)") + lines.append("") + + by_rw = sm.get("by_reward", {}) + best = by_rw.get("best") + worst = by_rw.get("worst") + if best or worst: + cols = [("Best", best), ("Worst", worst)] + lines.append(" By reward outcome:") + lines.append(f" {'':30s}{''.join(f'{lbl:>20s}' for lbl, _ in cols)}") + + def _col_val(grp: dict | None, key: str, sub: str = "mean") -> str: + if not grp: + return f"{'N/A':>20s}" + v = grp.get(key) + if isinstance(v, dict): + return f"{v[sub]:>20.1f}" + if isinstance(v, int | float): + return f"{v:>20.1%}" + return f"{'N/A':>20s}" + + for row_label, key, sub in [ + ("Tool calls (mean):", "tool_calls_per_rollout", "mean"), + ("Tool calls (median):", "tool_calls_per_rollout", "median"), + ("Steps (mean):", "steps_per_rollout", "mean"), + ("Tool error rate:", "tool_error_rate", "mean"), + ]: + vals = "".join(_col_val(grp, key, sub) for _, grp in cols) + lines.append(f" {row_label:30s}{vals}") + + for row_label, outcome_key in [("Completed:", "Completed"), ("Truncated:", None)]: + parts: list[str] = [] + for _, grp in cols: + if not grp: + parts.append(f"{'N/A':>20s}") + continue + od = grp.get("outcome_distribution", {}) + n = grp["count"] + if outcome_key: + c = od.get(outcome_key, 0) + else: + c = sum(v for k, v in od.items() if k.startswith("Truncated")) + parts.append(f"{c:>10d} ({c / n * 100:4.1f}%)") + lines.append(f" {row_label:30s}{''.join(parts)}") + + intermediate_exc = by_rw.get("intermediate_excluded") + if intermediate_exc: + lines.append( + f" ({intermediate_exc} intermediate-reward rollouts excluded from stratification)" + ) + + lines.append("") + return lines + + +# --------------------------------------------------------------------------- +# Main analysis +# --------------------------------------------------------------------------- + def analyze( rollout_file: str, output_dir: str, title: str = "ROLLOUT ANALYSIS", + judge_schema: str = "auto", ) -> None: out = Path(output_dir) out.mkdir(parents=True, exist_ok=True) @@ -59,31 +511,41 @@ def analyze( total = len(rollouts) rewards = [r.get("reward", 0.0) for r in rollouts] - correct = [r for r in rollouts if r.get("reward", 0.0) == 1.0] - incorrect = [r for r in rollouts if r.get("reward", 0.0) == 0.0] - partial = [r for r in rollouts if 0.0 < r.get("reward", 0.0) < 1.0] + max_reward = max(rewards) + min_reward = min(rewards) + best = [r for r in rollouts if r.get("reward", 0.0) == max_reward] + worst = [r for r in rollouts if r.get("reward", 0.0) == min_reward] + intermediate = [r for r in rollouts if min_reward < r.get("reward", 0.0) < max_reward] avg_reward = sum(rewards) / total - min_reward = min(rewards) - max_reward = max(rewards) + # -- Judge analysis --------------------------------------------------- + schema = judge_schema if judge_schema != "auto" else _detect_judge_schema(rollouts) verdict_counts: Counter = Counter() - judge_failed = [] - for r in rollouts: - evals = r.get("judge_evaluations", []) - if not evals: - judge_failed.append(r) - for ev in evals: - verdict_counts[ev.get("verdict_label", "UNKNOWN")] += 1 + judge_failed: list[dict] = [] + + if schema is not None: + handler = _SCHEMA_HANDLERS.get(schema) + if handler is None: + logger.warning("Unknown judge schema '%s', ignoring judge analysis", schema) + schema = None + else: + verdict_counts, judge_failed = handler(rollouts) + # -- Summary ---------------------------------------------------------- lines = [ title, "=" * 60, f"Total samples: {total}", - f"Correct (1.0): {len(correct):5d} ({len(correct) / total * 100:5.1f}%)", - f"Incorrect (0.0): {len(incorrect):5d} ({len(incorrect) / total * 100:5.1f}%)", - f"Partial (0 0.95: - lines.append(" WARNING: Very high baseline (>95%) -- limited room for RL improvement.") - elif avg_reward < 0.05: - lines.append(" WARNING: Very low baseline (<5%) -- model may struggle to learn.") + reward_std = statistics.stdev(rewards) if len(rewards) > 1 else 0.0 + if max_reward == min_reward: + lines.append(" WARNING: All rewards identical -- no RL signal.") + elif reward_std < 0.01 * (max_reward - min_reward): + lines.append(" WARNING: Very low variance -- weak RL signal.") else: - lines.append(f" OK: Mixed rewards ({avg_reward:.1%} accuracy) -- good signal for RL.") + lines.append( + f" OK: Mixed rewards (mean={avg_reward:.4f}, std={reward_std:.4f}) -- good signal for RL." + ) lines.append("") + # -- Token metrics (unconditional) ------------------------------------ + token_metrics = _extract_token_metrics(rollouts, max_reward=max_reward, min_reward=min_reward) + all_metrics: dict = {} + if token_metrics: + lines.extend(_format_token_section(token_metrics)) + all_metrics["token_metrics"] = token_metrics + + # -- Step metrics (multi-step only) ------------------------------------ + step_metrics = _extract_step_metrics(rollouts, max_reward=max_reward, min_reward=min_reward) + if step_metrics: + lines.extend(_format_step_section(step_metrics)) + all_metrics["step_metrics"] = step_metrics + lines.append("Interactive viewer (browse individual rollouts with Gradio):") lines.append(f" ng_viewer +jsonl_fpath={rollout_file}") lines.append("=" * 60) @@ -121,22 +595,29 @@ def analyze( logger.info("\n%s", summary) (out / "summary.txt").write_text(summary + "\n") - def _save(name, items): + if all_metrics: + (out / "step_metrics.json").write_text(json.dumps(all_metrics, indent=2) + "\n") + logger.info(" Saved metrics -> step_metrics.json") + + def _save(name: str, items: list[dict]) -> None: if items: with open(out / f"{name}.jsonl", "w") as f: for item in items: f.write(json.dumps(item) + "\n") logger.info(" Saved %d samples -> %s.jsonl", len(items), name) - _save("correct", correct) - _save("incorrect", incorrect) - _save("partial", partial) + _save("best", best) + _save("worst", worst) + _save("intermediate", intermediate) _save("judge_failed", judge_failed) if __name__ == "__main__": if len(sys.argv) < 3: - logger.error("Usage: python analyze_rollouts.py [title]") + logger.error( + "Usage: python analyze_rollouts.py [title] [judge_schema]" + ) sys.exit(1) - title = sys.argv[3] if len(sys.argv) > 3 else "ROLLOUT ANALYSIS" - analyze(sys.argv[1], sys.argv[2], title) + _title = sys.argv[3] if len(sys.argv) > 3 else "ROLLOUT ANALYSIS" + _schema = sys.argv[4] if len(sys.argv) > 4 else "auto" + analyze(sys.argv[1], sys.argv[2], _title, judge_schema=_schema) diff --git a/nvflow/recipes/finance/utils/rl/apply_validate_filter.py b/nvflow/recipes/finance/utils/rl/apply_validate_filter.py new file mode 100644 index 0000000..900c00e --- /dev/null +++ b/nvflow/recipes/finance/utils/rl/apply_validate_filter.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Phase 2 filter applier for the ``validate_questions`` GRPO stage. + +Reads the parsed JSONL produced by ``parse_validate_responses.py`` and +splits it into two streams by the ``validate_tag`` field: + +- Records with ``validate_tag == keep_tag`` (default ``"VALID"``) go to + the kept output (normally ``final_result.jsonl``), which + ``data_transformation`` reads next. +- Records with other tags go to the dropped output for audit. + +A stats JSON is also written with total / kept / dropped counts, the +drop rate, and a non-fatal ``high_drop_warning`` flag. Mirrors the SDG +``apply_answer_filter.py`` pattern. +""" + +import argparse +from pathlib import Path + +import orjson + +from nvflow.utils import setup_logger + +logger = setup_logger(__name__) + +WRITE_BUFFER_SIZE = 1000 + +# Non-SDG fields injected by nemo-skills generate() + the LLM provider + +# our parse step. Stripped before writing so the output preserves the +# raw SDG schema (validate_questions is a pure row-filter). +# ``reasoning_content`` is NOT here -- it's a legitimate SDG field that +# gets overwritten by the LLM; the restore in apply_validate_filter puts +# the SDG-original value back, so we must not strip it. +_STRIP_FIELDS = frozenset( + { + "generation", # LLM classifier "Reason: ... Answer: VALID" text + "finish_reason", + "num_generated_tokens", + "num_input_tokens", + "generation_start_time", + "generation_end_time", + "generation_time", + "serialized_output", + "provider_specific_fields", + "validate_tag", # internal -- consumed by the split below + "validate_explanation", + "validate_parse_failed", + "validate_parse_error", + } +) + + +def _strip_pollution(row: dict) -> dict: + for k in _STRIP_FIELDS: + row.pop(k, None) + return row + + +def _load_raw_sdg_records(raw_sdg_path: str) -> dict[str, bytes]: + """Build ``{problem -> original_record_bytes}`` from the raw SDG file. + + validate_questions is a pure row-filter: output records must be + identical to input records, just fewer of them. We index original + records by ``problem`` so that apply_validate_filter can emit the + original record (unchanged) for each VALID verdict. + """ + problem_to_record: dict[str, bytes] = {} + with open(raw_sdg_path, "rb") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = orjson.loads(line) + except orjson.JSONDecodeError: + continue + problem = obj.get("problem") + if not problem: + continue + if problem not in problem_to_record: + problem_to_record[problem] = line + return problem_to_record + + +def apply_validate_filter( + input_file: str, + output_kept: str, + output_dropped: str, + stats_file: str, + keep_tag: str = "VALID", + high_drop_threshold: float = 0.20, + raw_sdg_path: str | None = None, +) -> dict: + """Split records by ``validate_tag`` into kept / dropped streams. + + Args: + input_file: JSONL with ``validate_tag`` field on each row. + output_kept: JSONL receiving ``keep_tag`` records (default VALID). + output_dropped: JSONL receiving all other records, with + ``_llm_drop_reason`` annotated. + stats_file: JSON with counts and drop-rate warning flag. + keep_tag: tag value to keep (default ``"VALID"``). + high_drop_threshold: fraction above which ``high_drop_warning`` + is set in the stats file (default 0.20). + raw_sdg_path: Optional path to the raw SDG JSONL file used to + restore ``reasoning_content`` per record (see + :func:`_load_raw_sdg_reasoning` for rationale). When provided, + the field is overwritten with the SDG-original value before + the pollution strip, guaranteeing SDG-schema-faithful output. + Recommended for GRPO (``dataset_transformer`` requires a + non-empty ``reasoning_content`` on single-seed SDG records). + + Returns: + The stats dict that was written to ``stats_file``. + """ + num_total = 0 + num_kept = 0 + num_dropped = 0 + num_missing_tag = 0 + num_parse_failed = 0 + num_original_used = 0 + num_original_missing = 0 + + problem_to_record: dict[str, bytes] | None = None + if raw_sdg_path: + logger.info("Loading raw SDG records from %s", raw_sdg_path) + problem_to_record = _load_raw_sdg_records(raw_sdg_path) + logger.info(" %d unique problems loaded from raw SDG", len(problem_to_record)) + + kept_buffer: list[bytes] = [] + dropped_buffer: list[bytes] = [] + + with ( + open(input_file, "rb") as reader, + open(output_kept, "wb") as kept_writer, + open(output_dropped, "wb") as dropped_writer, + ): + for line in reader: + line = line.strip() + if not line: + continue + + num_total += 1 + + try: + row = orjson.loads(line) + except orjson.JSONDecodeError as exc: + # Parse error on a row that's already supposed to be + # post-parse. Count as a drop with a clear reason. + num_dropped += 1 + dropped_buffer.append( + orjson.dumps( + { + "_llm_drop_reason": "malformed_parsed_json", + "_llm_drop_error": str(exc), + } + ) + ) + continue + + # Resolve the original SDG record for this problem. When + # raw_sdg_path is provided, the output is the ORIGINAL record + # (unchanged) -- validate_questions is a pure row-filter. + # When not provided, fall back to stripping nemo-skills + # pollution from the generate() output (legacy behavior). + original_record_bytes: bytes | None = None + if problem_to_record is not None: + original_record_bytes = problem_to_record.get(row.get("problem", "")) + if original_record_bytes is not None: + num_original_used += 1 + else: + num_original_missing += 1 + + tag = row.get("validate_tag") + if row.get("validate_parse_failed"): + num_parse_failed += 1 + + if tag is None: + num_missing_tag += 1 + num_dropped += 1 + # Emit reason BEFORE strip so _llm_drop_reason survives + # (it's not in _STRIP_FIELDS -- it's audit metadata on the + # dropped-only stream, fine to keep). + row["_llm_drop_reason"] = "missing_validate_tag" + dropped_buffer.append(orjson.dumps(_strip_pollution(row))) + elif tag == keep_tag: + num_kept += 1 + if original_record_bytes is not None: + kept_buffer.append(original_record_bytes) + else: + kept_buffer.append(orjson.dumps(_strip_pollution(row))) + else: + num_dropped += 1 + row["_llm_drop_reason"] = f"tag={tag}" + dropped_buffer.append(orjson.dumps(_strip_pollution(row))) + + if len(kept_buffer) >= WRITE_BUFFER_SIZE: + kept_writer.write(b"\n".join(kept_buffer) + b"\n") + kept_buffer.clear() + if len(dropped_buffer) >= WRITE_BUFFER_SIZE: + dropped_writer.write(b"\n".join(dropped_buffer) + b"\n") + dropped_buffer.clear() + + if kept_buffer: + kept_writer.write(b"\n".join(kept_buffer) + b"\n") + if dropped_buffer: + dropped_writer.write(b"\n".join(dropped_buffer) + b"\n") + + drop_rate = num_dropped / num_total if num_total else 0.0 + high_drop_warning = drop_rate > high_drop_threshold + + stats = { + "num_total": num_total, + "num_kept": num_kept, + "num_dropped": num_dropped, + "num_missing_tag": num_missing_tag, + "num_parse_failed_kept_as_valid": num_parse_failed, + "num_original_records_used": num_original_used, + "num_original_records_missing": num_original_missing, + "drop_rate": round(drop_rate, 6), + "keep_tag": keep_tag, + "high_drop_threshold": high_drop_threshold, + "high_drop_warning": high_drop_warning, + "input_file": input_file, + "output_kept": output_kept, + "output_dropped": output_dropped, + "raw_sdg_path": raw_sdg_path, + } + + with open(stats_file, "wb") as stats_writer: + stats_writer.write(orjson.dumps(stats, option=orjson.OPT_INDENT_2)) + + logger.info("validate filter summary") + logger.info(f" total: {num_total}") + logger.info(f" kept ({keep_tag}): {num_kept}") + logger.info(f" dropped: {num_dropped} ({drop_rate * 100:.2f}%)") + if num_missing_tag: + logger.info(f" missing tag (dropped): {num_missing_tag}") + if num_parse_failed: + logger.info(f" parse-failures kept as VALID (recall bias): {num_parse_failed}") + if problem_to_record is not None: + logger.info( + f" original records used: {num_original_used} missing: {num_original_missing}" + ) + if high_drop_warning: + logger.warning( + "drop rate %.2f%% exceeds threshold %.2f%% -- inspect %s before proceeding", + drop_rate * 100, + high_drop_threshold * 100, + output_dropped, + ) + + return stats + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Apply VALID/INVALID filter from validate_questions Phase 2 parsed output" + ) + parser.add_argument( + "--input_file", + required=True, + help="Parsed JSONL produced by parse_validate_responses.py (must have validate_tag).", + ) + parser.add_argument( + "--output_kept", + required=True, + help="Output JSONL with only VALID records (normally final_result.jsonl).", + ) + parser.add_argument( + "--output_dropped", + required=True, + help="Output JSONL with dropped records annotated with _llm_drop_reason.", + ) + parser.add_argument( + "--stats_file", + required=True, + help="Output JSON file with filter counts and high_drop_warning flag.", + ) + parser.add_argument( + "--keep_tag", + default="VALID", + help="Tag value to keep (default VALID).", + ) + parser.add_argument( + "--high_drop_threshold", + type=float, + default=0.20, + help="Drop-rate threshold for the high_drop_warning flag (default 0.20).", + ) + parser.add_argument( + "--raw_sdg_source", + default=None, + help=( + "Optional directory of the raw SDG JSONL. When set, restores " + "reasoning_content per record (nemo-skills' generate() overwrites " + "it with the LLM provider's reasoning, which dataset_transformer " + "rejects)." + ), + ) + parser.add_argument( + "--raw_sdg_filename", + default="final_result.jsonl", + help="Filename inside --raw_sdg_source (default: final_result.jsonl).", + ) + args = parser.parse_args() + + raw_sdg_path = ( + str(Path(args.raw_sdg_source) / args.raw_sdg_filename) if args.raw_sdg_source else None + ) + + apply_validate_filter( + args.input_file, + args.output_kept, + args.output_dropped, + args.stats_file, + keep_tag=args.keep_tag, + high_drop_threshold=args.high_drop_threshold, + raw_sdg_path=raw_sdg_path, + ) diff --git a/nvflow/recipes/finance/utils/rl/enrich_rollouts.py b/nvflow/recipes/finance/utils/rl/enrich_rollouts.py index 8d55f9e..2346d50 100644 --- a/nvflow/recipes/finance/utils/rl/enrich_rollouts.py +++ b/nvflow/recipes/finance/utils/rl/enrich_rollouts.py @@ -18,7 +18,8 @@ NeMo-Gym environments may drop extra input fields (uuid, question, template_metadata, etc.) because their Pydantic response models don't use ``extra="allow"``. This script restores those fields by matching -output rows to input rows using ``expected_answer`` as the join key. +output rows to input rows using ``expected_answer`` + prompt content +as the join key. For each match: ``enriched = input_row | output_row`` -- input fields serve as defaults, output fields (response, reward, etc.) take @@ -38,7 +39,6 @@ import json import sys import uuid as uuid_mod -from collections import defaultdict from nvflow.utils import setup_logger @@ -66,19 +66,30 @@ def _deterministic_uuid(row: dict, index: int) -> str: return str(uuid_mod.UUID(hashlib.md5(key.encode()).hexdigest())) -def _ensure_uuids(inputs: list[dict]) -> int: - """Add in-memory uuid to any input row missing one. Returns count generated. +def _ensure_uuids(inputs: list[dict], input_file: str | None = None) -> int: + """Add uuid to any input row missing one. Returns count generated. UUIDs are deterministic (derived from expected_answer + problem + row index) so that concurrent merge jobs across seeds produce the same UUID for the same question. The row index guarantees uniqueness even when content - fields are duplicated. The input file is NOT rewritten. + fields are duplicated. + + When *input_file* is provided and UUIDs were generated, the input file + is rewritten so that downstream steps (e.g. filter) can join on uuid. + Because UUIDs are deterministic, concurrent seeds writing the same file + produce identical content -- safe even under race conditions. """ generated = 0 for i, row in enumerate(inputs): if "uuid" not in row: row["uuid"] = _deterministic_uuid(row, i) generated += 1 + + if generated and input_file: + with open(input_file, "w") as f: + for row in inputs: + f.write(json.dumps(row) + "\n") + return generated @@ -93,42 +104,35 @@ def enrich(input_file: str, rollouts_file: str) -> None: logger.warning("No rollouts to enrich.") return - # Ensure every input row has a uuid (in-memory only). - num_generated = _ensure_uuids(inputs) + num_generated = _ensure_uuids(inputs, input_file=input_file) if num_generated: - logger.info("Generated in-memory UUIDs for %d/%d input rows", num_generated, len(inputs)) + logger.info( + "Generated UUIDs for %d/%d input rows (written back to %s)", + num_generated, + len(inputs), + input_file, + ) - # Fast path: if the environment already passes through all input fields - # (e.g. extra="allow"), there's nothing to restore. However, if we - # generated deterministic UUIDs above, we must still enrich so that the - # rollout output gets the correct (stable) UUID for cross-seed grouping. sample_input_keys = set(inputs[0].keys()) if inputs else set() sample_output_keys = set(rollouts[0].keys()) missing_keys = sample_input_keys - sample_output_keys - if not missing_keys and not num_generated: - logger.info("All input fields already present in output -- nothing to enrich.") - return - # Build lookup: expected_answer -> list of input rows. - # Most datasets have unique expected_answer per question, but we handle - # duplicates by also matching on the prompt content. - by_answer: dict[str, list[dict]] = defaultdict(list) + def _match_key(row: dict) -> str: + return row.get("expected_answer", "") + "|" + _extract_prompt(row) + + by_key: dict[str, dict] = {} + key_collisions = 0 for row in inputs: - by_answer[row.get("expected_answer", "")].append(row) + k = _match_key(row) + if k in by_key: + key_collisions += 1 + else: + by_key[k] = row + if key_collisions: + logger.warning("%d input rows share the same (expected_answer, prompt) key", key_collisions) def _find_input(rollout: dict) -> dict | None: - ea = rollout.get("expected_answer", "") - candidates = by_answer.get(ea, []) - if len(candidates) == 1: - return candidates[0] - if not candidates: - return None - # Disambiguate by prompt content. - prompt = _extract_prompt(rollout) - for c in candidates: - if c.get("question", "") and prompt and c["question"] in prompt: - return c - return candidates[0] + return by_key.get(_match_key(rollout)) matched = 0 unmatched = 0 @@ -136,8 +140,8 @@ def _find_input(rollout: dict) -> dict | None: for rollout in rollouts: match = _find_input(rollout) if match: - merged = match | rollout - if num_generated and "uuid" in match: + merged = {**match, **rollout} + if "uuid" in match: merged["uuid"] = match["uuid"] matched += 1 else: diff --git a/nvflow/recipes/finance/utils/rl/filter_training_data.py b/nvflow/recipes/finance/utils/rl/filter_training_data.py index 43a492a..f86b50f 100644 --- a/nvflow/recipes/finance/utils/rl/filter_training_data.py +++ b/nvflow/recipes/finance/utils/rl/filter_training_data.py @@ -13,24 +13,22 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Filter training data using reward-profile difficulty analysis. +"""Filter training data using reward-variance difficulty analysis. Joins ``train.jsonl`` (from prepare_data) with ``difficulty.jsonl`` (from collect_rollouts or compute_rewards aggregate) on ``uuid`` and -keeps only questions whose pass rate falls within a configurable -"sweet spot" range. Questions that are too hard (pass_rate == 0) or -too easy (pass_rate == 1) provide little GRPO learning signal and are -removed by default. +keeps only questions whose reward variance exceeds a minimum threshold. +Questions with zero reward variance (all seeds got the same reward) +produce no GRPO gradient and are removed. Questions not found in ``difficulty.jsonl`` (unprofiled) are kept by -default -- only explicitly identified too-hard / too-easy questions are -removed. +default -- only explicitly identified zero-signal questions are removed. Standalone script that runs inside the Slurm container with python3. Usage: python filter_training_data.py \\ - [--min-pass-rate 0.0] [--max-pass-rate 1.0] [--validation-data ] + [--min-reward-std 1e-6] [--validation-data ] Produces: /train.jsonl -- filtered training data (same schema) @@ -55,19 +53,28 @@ def filter_training_data( difficulty_path: str, output_dir: str, *, - min_pass_rate: float = 0.0, - max_pass_rate: float = 1.0, + min_reward_std: float = 1e-6, validation_path: str | None = None, + policy_model: str | None = None, + judge_model: str | None = None, + train_filename: str = "train.jsonl", + val_filename: str = "validation.jsonl", + report_filename: str = "filter_report.json", ) -> dict[str, Any]: - """Filter training data by pass-rate thresholds. + """Filter training data by reward variance threshold. Args: train_path: Path to prepare_data train.jsonl. difficulty_path: Path to aggregate/difficulty.jsonl. output_dir: Directory for filtered output files. - min_pass_rate: Exclusive lower bound (questions with pass_rate <= min are removed). - max_pass_rate: Exclusive upper bound (questions with pass_rate >= max are removed). + min_reward_std: Minimum reward_std to keep (questions below are removed). validation_path: Optional path to validation.jsonl (copied unchanged). + policy_model: Optional policy model name for provenance in difficulty_profile. + judge_model: Optional judge model name for provenance in difficulty_profile. + train_filename: Filename for the filtered training output (default "train.jsonl"). + val_filename: Filename for the validation output (default "validation.jsonl"). + report_filename: Filename for the JSON filter report written to + ``{output_dir}/filter/`` (default "filter_report.json"). Returns: Report dict with filtering statistics. @@ -81,15 +88,18 @@ def filter_training_data( diff_file = Path(difficulty_path) if not diff_file.exists(): logger.warning("difficulty file not found: %s", difficulty_path) - logger.info("Passthrough mode: copying train.jsonl unchanged.") - shutil.copy2(train_path, out / "train.jsonl") + logger.info("Passthrough mode: copying train unchanged.") + shutil.copy2(train_path, out / train_filename) if validation_path and Path(validation_path).exists(): - shutil.copy2(validation_path, out / "validation.jsonl") + shutil.copy2(validation_path, out / val_filename) report: dict[str, Any] = {"mode": "passthrough", "reason": "difficulty.jsonl not found"} - _write_report(filter_dir, report) + _write_report(filter_dir, report, report_filename=report_filename) return report - pass_rates: dict[str, float] = {} + # Load difficulty records to merge into output and use for filtering. + profile_fields = ("avg_reward", "reward_std", "reward_min", "reward_max", "n", "c", "pass@1") + + difficulty: dict[str, dict[str, Any]] = {} with open(diff_file) as f: for line in f: line = line.strip() @@ -98,20 +108,19 @@ def filter_training_data( rec = json.loads(line) uid = rec.get("uuid", "") if uid: - pass_rates[uid] = rec.get("pass_rate", 0.0) + difficulty[uid] = {k: rec.get(k) for k in profile_fields if k in rec} - logger.info("Loaded %d questions from difficulty.jsonl", len(pass_rates)) - logger.info("Filter: keep %s < pass_rate < %s", min_pass_rate, max_pass_rate) + logger.info("Loaded %d questions from difficulty.jsonl", len(difficulty)) + logger.info("Filter: keep reward_std >= %s", min_reward_std) total = 0 kept = 0 kept_no_profile = 0 - removed_too_hard = 0 - removed_too_easy = 0 + removed_no_signal = 0 by_type_total: Counter = Counter() by_type_kept: Counter = Counter() - with open(train_path) as fin, open(out / "train.jsonl", "w") as fout: + with open(train_path) as fin, open(out / train_filename, "w") as fout: for line in fin: line = line.strip() if not line: @@ -122,38 +131,41 @@ def filter_training_data( qtype = row.get("question_type", "unknown") by_type_total[qtype] += 1 - if uid not in pass_rates: + diff_rec = difficulty.get(uid) + + if diff_rec is None: kept += 1 kept_no_profile += 1 by_type_kept[qtype] += 1 fout.write(json.dumps(row) + "\n") continue - pr = pass_rates[uid] - if pr <= min_pass_rate: - removed_too_hard += 1 - continue - if pr >= max_pass_rate: - removed_too_easy += 1 + rs = diff_rec.get("reward_std", 0.0) + if rs < min_reward_std: + removed_no_signal += 1 continue + profile = dict(diff_rec) + if policy_model: + profile["policy_model"] = policy_model + if judge_model: + profile["judge_model"] = judge_model + row["difficulty_profile"] = profile kept += 1 by_type_kept[qtype] += 1 fout.write(json.dumps(row) + "\n") if validation_path and Path(validation_path).exists(): - shutil.copy2(validation_path, out / "validation.jsonl") - logger.info("Validation data copied unchanged -> %s", out / "validation.jsonl") + shutil.copy2(validation_path, out / val_filename) + logger.info("Validation data copied unchanged -> %s", out / val_filename) report = { "mode": "filtered", - "min_pass_rate": min_pass_rate, - "max_pass_rate": max_pass_rate, + "min_reward_std": min_reward_std, "total_questions": total, "kept": kept, "kept_no_profile": kept_no_profile, - "removed_too_hard": removed_too_hard, - "removed_too_easy": removed_too_easy, + "removed_no_signal": removed_no_signal, "kept_pct": kept / total if total > 0 else 0.0, "by_question_type": { qt: {"total": by_type_total[qt], "kept": by_type_kept[qt]} @@ -161,15 +173,18 @@ def filter_training_data( }, } - _write_report(filter_dir, report) + _write_report(filter_dir, report, report_filename=report_filename) _print_summary(report) return report -def _write_report(report_dir: Path, report: dict) -> None: - with open(report_dir / "filter_report.json", "w") as f: +def _write_report( + report_dir: Path, report: dict, *, report_filename: str = "filter_report.json" +) -> None: + report_path = report_dir / report_filename + with open(report_path, "w") as f: json.dump(report, f, indent=2) - logger.info("Report -> %s", report_dir / "filter_report.json") + logger.info("Report -> %s", report_path) def _print_summary(report: dict) -> None: @@ -182,8 +197,7 @@ def _print_summary(report: dict) -> None: f"Total questions: {total}", f"Kept (total): {report['kept']} ({report['kept_pct']:.1%})", f" Kept (no profile): {report['kept_no_profile']}", - f"Removed (too hard): {report['removed_too_hard']}", - f"Removed (too easy): {report['removed_too_easy']}", + f"Removed (no signal): {report['removed_no_signal']}", "", ] @@ -209,29 +223,55 @@ def _print_summary(report: dict) -> None: parser.add_argument("difficulty_path", help="Path to difficulty.jsonl") parser.add_argument("output_dir", help="Output directory") parser.add_argument( - "--min-pass-rate", + "--min-reward-std", type=float, - default=0.0, - help="Exclusive lower bound (default: 0.0, removes 0%% pass rate)", - ) - parser.add_argument( - "--max-pass-rate", - type=float, - default=1.0, - help="Exclusive upper bound (default: 1.0, removes 100%% pass rate)", + default=1e-6, + help="Minimum reward_std to keep (default: 1e-6, removes zero-variance questions)", ) parser.add_argument( "--validation-data", default=None, help="Path to validation.jsonl (copied unchanged)", ) + parser.add_argument( + "--policy-model", + default=None, + help="Policy model name for provenance in difficulty_profile (optional)", + ) + parser.add_argument( + "--judge-model", + default=None, + help="Judge model name for provenance in difficulty_profile (optional)", + ) + parser.add_argument( + "--train-filename", + default="train.jsonl", + help="Filename for the filtered training output (default: train.jsonl). " + "Match the consumer's expected filename (training.py train_filename).", + ) + parser.add_argument( + "--val-filename", + default="validation.jsonl", + help="Filename for the validation output (default: validation.jsonl). " + "Match the consumer's expected filename (training.py val_filename).", + ) + parser.add_argument( + "--report-filename", + default="filter_report.json", + help="Filename for the filter-report JSON inside {output_dir}/filter/ " + "(default: filter_report.json).", + ) args = parser.parse_args() filter_training_data( args.train_path, args.difficulty_path, args.output_dir, - min_pass_rate=args.min_pass_rate, - max_pass_rate=args.max_pass_rate, + min_reward_std=args.min_reward_std, validation_path=args.validation_data, + policy_model=args.policy_model, + judge_model=args.judge_model, + train_filename=args.train_filename, + val_filename=args.val_filename, + report_filename=args.report_filename, ) diff --git a/nvflow/recipes/finance/utils/rl/parse_validate_responses.py b/nvflow/recipes/finance/utils/rl/parse_validate_responses.py new file mode 100644 index 0000000..6d3573d --- /dev/null +++ b/nvflow/recipes/finance/utils/rl/parse_validate_responses.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Phase 2 response parser for the ``validate_questions`` GRPO stage. + +Reads the LLM generation output (the ``generation`` field in +nemo-skills' ``output.jsonl``), extracts the final +``Answer: VALID`` / ``Answer: INVALID`` tag, and attaches it as +``validate_tag``. Mirrors the SDG ``parse_filter_responses.py`` pattern. + +The ``validate_tag`` field is an **internal tag** used by the downstream +``apply_validate_filter.py`` to split VALID from INVALID rows. It and +other classifier/nemo-skills leftovers (``generation``, timing fields, +``serialized_output``, etc.) are stripped by ``apply_validate_filter.py`` +before writing ``final_result.jsonl`` / ``llm_dropped.jsonl``. +``reasoning_content`` is restored from the raw SDG file there (not +stripped -- it is a legitimate SDG field). Downstream consumers see an +SDG-schema-shaped record with the same fields as the input, just +filtered. + +**Recall bias.** On parse failure (missing tag, empty generation, garbled +output) we default to ``validate_tag = "VALID"`` so the downstream filter +keeps the record. Parse failures are counted separately in the companion +``*_parse_log.txt`` so unusual rates are visible. +""" + +import argparse +import re + +import orjson + +from nvflow.utils import setup_logger + +logger = setup_logger(__name__) + +WRITE_BUFFER_SIZE = 1000 + +# Accepts "Answer: VALID" / "Answer: INVALID" (case-insensitive). We take +# the LAST match to avoid false positives when the rubric/reasoning +# repeats the words earlier in the response. +_ANSWER_RE = re.compile(r"Answer:\s*(VALID|INVALID)", re.IGNORECASE) + + +def parse_validate_tag(generation_text: str) -> tuple[str | None, str | None, str | None]: + """Extract VALID/INVALID tag from generation text. + + Returns: + (tag, explanation, error_msg) + + - ``tag``: ``"VALID"`` or ``"INVALID"`` on success, ``None`` on parse failure. + - ``explanation``: the text preceding the final ``Answer:`` line, or ``None`` + if too short. + - ``error_msg``: description of why parsing failed, or ``None`` on success. + + On parse failure the CALLER defaults the tag to ``"VALID"`` (keep). + """ + if not generation_text or not isinstance(generation_text, str): + return None, None, "Empty or invalid generation text" + + matches = list(_ANSWER_RE.finditer(generation_text)) + if not matches: + return ( + None, + None, + f"No 'Answer: VALID/INVALID' pattern found in: {generation_text[:200]}", + ) + + last = matches[-1] + tag = last.group(1).upper() + + explanation = generation_text[: last.start()].strip() + if not explanation or len(explanation) < 10: + explanation = None + + return tag, explanation, None + + +def parse_validate_responses(input_file: str, output_file: str) -> None: + """Parse nemo-skills generation output and attach validate_tag. + + Args: + input_file: JSONL produced by nemo-skills ``generate()`` with a + ``generation`` field on each row. + output_file: JSONL with ``validate_tag`` (and optional + ``validate_explanation``) attached to every row. + """ + log_file = output_file.replace(".jsonl", "_parse_log.txt") + + num_total = 0 + num_parsed = 0 + num_parse_failed = 0 + num_valid = 0 + num_invalid = 0 + num_parse_failed_defaulted_valid = 0 + + buffer: list[bytes] = [] + + with ( + open(input_file, "rb") as reader, + open(output_file, "wb") as writer, + open(log_file, "w") as log_writer, + ): + for line in reader: + line = line.strip() + if not line: + continue + + num_total += 1 + + try: + row = orjson.loads(line) + except orjson.JSONDecodeError as exc: + msg = f"Failed to parse JSON at entry {num_total}: {exc}" + log_writer.write(msg + "\n") + num_parse_failed += 1 + # Can't attach anything useful — skip this line entirely. + continue + + generation = row.get("generation", "") + + tag, explanation, error = parse_validate_tag(generation) + + if tag is None: + # Recall bias: default to VALID on parse failure so we + # keep the record. Log for visibility. + num_parse_failed += 1 + num_parse_failed_defaulted_valid += 1 + row["validate_tag"] = "VALID" + row["validate_parse_failed"] = True + if error: + row["validate_parse_error"] = error[:300] + log_writer.write( + f"entry {num_total}: parse failure -> defaulting to VALID ({error})\n" + ) + else: + num_parsed += 1 + row["validate_tag"] = tag + if explanation: + row["validate_explanation"] = explanation + if tag == "VALID": + num_valid += 1 + else: + num_invalid += 1 + + buffer.append(orjson.dumps(row)) + if len(buffer) >= WRITE_BUFFER_SIZE: + writer.write(b"\n".join(buffer) + b"\n") + buffer.clear() + + if buffer: + writer.write(b"\n".join(buffer) + b"\n") + + log_writer.write(f"\n{'=' * 60}\n") + log_writer.write("VALIDATE PARSE SUMMARY\n") + log_writer.write(f"{'=' * 60}\n") + log_writer.write(f"Total entries: {num_total}\n") + log_writer.write(f"Successfully parsed: {num_parsed}\n") + log_writer.write(f"Parse failures (defaulted VALID): {num_parse_failed_defaulted_valid}\n") + log_writer.write(f"VALID: {num_valid}\n") + log_writer.write(f"INVALID: {num_invalid}\n") + if num_total: + log_writer.write(f"Parse success rate: {num_parsed / num_total * 100:.2f}%\n") + if num_parsed: + log_writer.write( + f"INVALID rate (of successfully parsed): {num_invalid / num_parsed * 100:.2f}%\n" + ) + + logger.info("validate parse summary") + logger.info(f" total: {num_total}") + logger.info(f" parsed: {num_parsed}") + logger.info(f" parse failed: {num_parse_failed_defaulted_valid} (defaulted VALID)") + logger.info(f" VALID: {num_valid}") + logger.info(f" INVALID: {num_invalid}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Parse VALID/INVALID tags from LLM generation output (validate_questions Phase 2)" + ) + parser.add_argument( + "--input_file", + required=True, + help="Input JSONL with nemo-skills 'generation' field.", + ) + parser.add_argument( + "--output_file", + required=True, + help="Output JSONL with validate_tag (and optional validate_explanation) attached.", + ) + args = parser.parse_args() + + parse_validate_responses(args.input_file, args.output_file) diff --git a/nvflow/recipes/finance/utils/rl/prompt_template_applier.py b/nvflow/recipes/finance/utils/rl/prompt_template_applier.py index 850b520..b6b4c20 100644 --- a/nvflow/recipes/finance/utils/rl/prompt_template_applier.py +++ b/nvflow/recipes/finance/utils/rl/prompt_template_applier.py @@ -15,9 +15,9 @@ """Apply a prompt template to SDG data and extract the expected answer. Reads chunked JSONL from data_transformation, applies a YAML prompt template -to create a ``prompt`` field (merging instruction + context + question), -and extracts the concise answer after a configurable prefix (e.g. "Answer:") -from the ``generation`` field. +to create a ``prompt`` field (merging instruction + context + question + +optional ``current_date``), and extracts the concise answer after a +configurable prefix (e.g. "Answer:") from the ``generation`` field. Input schema (6-field data_transformation output):: @@ -32,16 +32,29 @@ context -> "" (absorbed into prompt) generation -> unchanged (original full model output from SDG) +Dynamic ``current_date`` (GRPO only): +If ``--sec_metadata_parquet`` and ``--raw_sdg_source_dir`` are provided, the +template is also formatted with a per-record ``{current_date}`` resolved from +the SDG source filing's ``filing_date`` (from the parquet) plus a deterministic +jitter of ``jitter_min_days..jitter_max_days`` days seeded by ``record["uuid"]``. +Join key is ``problem`` (stable through data_transformation). Falls back to +``--fallback_current_date`` on any lookup miss. + Usage:: python -m nvflow.recipes.finance.utils.rl.prompt_template_applier \\ --prompt_template \\ - [--answer_prefix "Answer:"] + [--answer_prefix "Answer:"] \\ + [--sec_metadata_parquet --raw_sdg_source_dir \\ + --raw_sdg_filename ] """ import argparse import json +import random import sys +from dataclasses import dataclass, field +from datetime import datetime, timedelta from pathlib import Path import yaml @@ -51,11 +64,22 @@ logger = setup_logger(__name__) -def load_prompt_template(template_path: str) -> str: - """Load the user prompt template from a YAML file. +def load_prompt_template(template_path: str) -> dict: + """Load prompt template and optional response parameters from a YAML file. Expects a YAML file with a ``user`` key containing a format string - with ``{context}`` and ``{problem}`` placeholders. + with a required ``{problem}`` placeholder. ``{context}`` is + optional -- SFT templates include it, while the GRPO agent template + intentionally omits it (the policy discovers filings via tools + rather than receiving pre-loaded context). + + Agent-style templates may also include ``tools``, + ``parallel_tool_calls``, etc. These are + bundled into a ``response_params`` dict and passed through to the + Responses API converter (stage 2). + + Returns a dict with ``user_template`` (str) and optionally + ``response_params`` (dict). """ with open(template_path) as f: config = yaml.safe_load(f) @@ -65,30 +89,168 @@ def load_prompt_template(template_path: str) -> str: logger.error("Prompt template YAML must have a 'user' key: %s", template_path) sys.exit(1) - if "{context}" not in template or "{problem}" not in template: + if "{problem}" not in template: logger.warning( - "Template may be missing {context} or {problem} placeholders: %s", + "Template is missing required {problem} placeholder: %s", template_path, ) - return template + result: dict = {"user_template": template} + + response_params: dict = {} + for key in ("tools", "parallel_tool_calls"): + if key in config: + response_params[key] = config[key] + if response_params: + result["response_params"] = response_params + + return result + +class DateResolver: + """Per-record ``current_date`` resolver driven by SEC metadata + raw SDG. -def apply_template(record: dict, template: str) -> dict: + Loads two maps once at construction: + + 1. ``accession_to_filing_date``: from the SEC metadata parquet (output of + workflow-2-download-sec). Multiple parquet rows per accession + (primary_document + exhibits) are deduped on first occurrence. + 2. ``problem_to_accession``: from the raw SDG file. SDG ``file_path0`` + follows ``{ticker}/{form_type}/{year}/{accession}/{section}/{filename}`` + so the accession is the 4th path segment. Only ``problem`` and + ``file_path0`` are read to keep memory small; ``content0`` / ``context`` + are large and unused here. + + ``resolve(record)`` returns ``(current_date, "resolved" | "fallback")`` + where ``current_date`` is ``filing_date + random.Random(record.uuid)`` + jitter in ``[jitter_min_days, jitter_max_days]`` days (deterministic per + record). On any lookup miss, returns the fallback date tagged as + ``"fallback"`` so callers can count hits/misses. + """ + + def __init__( + self, + *, + parquet_path: str, + raw_sdg_path: str, + jitter_min_days: int, + jitter_max_days: int, + fallback_current_date: str, + parquet_accession_column: str, + parquet_filing_date_column: str, + ) -> None: + import pandas as pd + + df = pd.read_parquet( + parquet_path, + columns=[parquet_accession_column, parquet_filing_date_column], + ).drop_duplicates(subset=[parquet_accession_column]) + accession_to_filing_date: dict[str, str] = {} + for accession, filing_date in zip( + df[parquet_accession_column], df[parquet_filing_date_column], strict=False + ): + if pd.isna(filing_date): + continue + accession_to_filing_date[str(accession)] = str(filing_date)[:10] + + problem_to_accession: dict[str, str] = {} + with open(raw_sdg_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + problem = obj.get("problem") + file_path0 = obj.get("file_path0", "") + if not problem or not file_path0: + continue + parts = file_path0.split("/") + if len(parts) < 4: + continue + problem_to_accession.setdefault(problem, parts[3]) + + self._accession_to_filing_date = accession_to_filing_date + self._problem_to_accession = problem_to_accession + self._jitter_min_days = jitter_min_days + self._jitter_max_days = jitter_max_days + self._fallback = fallback_current_date + + @property + def num_accessions(self) -> int: + return len(self._accession_to_filing_date) + + @property + def num_problems(self) -> int: + return len(self._problem_to_accession) + + def resolve(self, record: dict) -> tuple[str, str]: + problem = record.get("problem", "") + accession = self._problem_to_accession.get(problem) + if not accession: + return self._fallback, "fallback" + filing_date_str = self._accession_to_filing_date.get(accession) + if not filing_date_str: + return self._fallback, "fallback" + try: + filing_date = datetime.strptime(filing_date_str, "%Y-%m-%d") + except ValueError: + return self._fallback, "fallback" + seed = record.get("uuid") or problem + delta = random.Random(seed).randint(self._jitter_min_days, self._jitter_max_days) + return (filing_date + timedelta(days=delta)).strftime("%Y-%m-%d"), "resolved" + + +@dataclass +class ProcessFileStats: + """Counters returned by :func:`process_file`.""" + + processed: int = 0 + extracted: int = 0 + date_hits: int = 0 + date_fallbacks: int = 0 + errors: list[dict] = field(default_factory=list) + + +def apply_template( + record: dict, + template: str, + response_params: dict | None = None, + current_date: str | None = None, +) -> dict: """Apply the prompt template to a single record. Formats the template with ``context`` and ``problem`` from the record, stores the result in a new ``prompt`` field, keeps ``problem`` unchanged (raw question), and clears ``context``. + + When *response_params* is provided (e.g. tools, parallel_tool_calls from an + agent-style template), it is attached as ``_response_params`` so that + the downstream Responses API converter can merge it into + ``responses_create_params``. + + When *current_date* is provided, it is also passed to ``template.format()`` + so templates that reference ``{current_date}`` can render a per-record + "as of X" anchor. Templates that do not reference ``{current_date}`` + ignore the extra kwarg transparently. """ context = record.get("context", "") problem = record.get("problem", "") - formatted_prompt = template.format(context=context, problem=problem) + format_kwargs: dict[str, str] = {"context": context, "problem": problem} + if current_date is not None: + format_kwargs["current_date"] = current_date + formatted_prompt = template.format(**format_kwargs) result = dict(record) result["prompt"] = formatted_prompt result["context"] = "" + if current_date is not None: + result["current_date"] = current_date + if response_params: + result["_response_params"] = response_params return result @@ -111,14 +273,12 @@ def process_file( output_path: Path, template: str, answer_prefix: str | None, -) -> tuple[int, int, list[dict]]: - """Process a single JSONL file. - - Returns (processed_count, extracted_count, error_rows). - """ - processed = 0 - extracted = 0 - errors: list[dict] = [] + response_params: dict | None = None, + date_resolver: DateResolver | None = None, +) -> ProcessFileStats: + """Process a single JSONL file. ``date_*`` counters stay zero when + ``date_resolver`` is ``None`` (dynamic-date resolution disabled).""" + stats = ProcessFileStats() with open(input_path) as fin, open(output_path, "w") as fout: for line_num, line in enumerate(fin, 1): @@ -129,24 +289,32 @@ def process_file( record = json.loads(line) except json.JSONDecodeError as e: logger.warning("Skipping malformed JSON at %s:%d: %s", input_path, line_num, e) - errors.append({"file": str(input_path), "line": line_num, "error": str(e)}) + stats.errors.append({"file": str(input_path), "line": line_num, "error": str(e)}) continue - result = apply_template(record, template) + current_date: str | None = None + if date_resolver is not None: + current_date, source = date_resolver.resolve(record) + if source == "resolved": + stats.date_hits += 1 + else: + stats.date_fallbacks += 1 + + result = apply_template(record, template, response_params, current_date=current_date) generation = result.get("generation", "") if answer_prefix: answer = extract_answer(generation, answer_prefix) result["expected_answer"] = answer if answer != generation: - extracted += 1 + stats.extracted += 1 else: result["expected_answer"] = generation fout.write(json.dumps(result, ensure_ascii=False) + "\n") - processed += 1 + stats.processed += 1 - return processed, extracted, errors + return stats def main() -> int: @@ -161,6 +329,50 @@ def main() -> int: default=None, help='Prefix to extract answer after (e.g. "Answer:"). If not set, generation is kept as-is.', ) + # Dynamic current_date resolution (all optional; only fires when both + # --sec_metadata_parquet and --raw_sdg_source_dir are provided). + parser.add_argument( + "--sec_metadata_parquet", + default=None, + help="Path to SEC metadata parquet (produced by workflow-2-download-sec).", + ) + parser.add_argument( + "--raw_sdg_source_dir", + default=None, + help="Directory containing the raw SDG file (used to build problem -> accession).", + ) + parser.add_argument( + "--raw_sdg_filename", + default="final_result.jsonl", + help="Filename inside --raw_sdg_source_dir (default: final_result.jsonl).", + ) + parser.add_argument( + "--jitter_min_days", + type=int, + default=1, + help="Minimum days added to filing_date for current_date jitter (default: 1).", + ) + parser.add_argument( + "--jitter_max_days", + type=int, + default=60, + help="Maximum days added to filing_date for current_date jitter (default: 60).", + ) + parser.add_argument( + "--fallback_current_date", + default="2025-04-07", + help="Date used when parquet lookup misses (default: 2025-04-07 matching eval template).", + ) + parser.add_argument( + "--parquet_accession_column", + default="accession_number", + help="Column name for accession in the parquet (default: accession_number).", + ) + parser.add_argument( + "--parquet_filing_date_column", + default="filing_date", + help="Column name for filing date in the parquet (default: filing_date).", + ) args = parser.parse_args() input_dir = Path(args.input_dir) @@ -175,8 +387,38 @@ def main() -> int: logger.info("Template: %s", args.prompt_template) logger.info("Answer prefix: %s", args.answer_prefix or "(none -- keep full generation)") - template = load_prompt_template(args.prompt_template) + tmpl = load_prompt_template(args.prompt_template) + template = tmpl["user_template"] + response_params = tmpl.get("response_params") logger.info("Template loaded (%d chars)", len(template)) + if response_params: + logger.info("Response params: %s", list(response_params.keys())) + + date_resolver: DateResolver | None = None + if args.sec_metadata_parquet and args.raw_sdg_source_dir: + raw_sdg_path = Path(args.raw_sdg_source_dir) / args.raw_sdg_filename + logger.info("Dynamic current_date enabled:") + logger.info(" Parquet: %s", args.sec_metadata_parquet) + logger.info(" Raw SDG: %s", raw_sdg_path) + logger.info(" Jitter range: [%d, %d] days", args.jitter_min_days, args.jitter_max_days) + logger.info(" Fallback date: %s", args.fallback_current_date) + logger.info("Building DateResolver (parquet + raw SDG scan)...") + date_resolver = DateResolver( + parquet_path=args.sec_metadata_parquet, + raw_sdg_path=str(raw_sdg_path), + jitter_min_days=args.jitter_min_days, + jitter_max_days=args.jitter_max_days, + fallback_current_date=args.fallback_current_date, + parquet_accession_column=args.parquet_accession_column, + parquet_filing_date_column=args.parquet_filing_date_column, + ) + logger.info( + " %d accessions, %d unique problems", + date_resolver.num_accessions, + date_resolver.num_problems, + ) + else: + logger.info("Dynamic current_date: DISABLED (missing parquet or raw_sdg_source_dir)") jsonl_files = sorted(input_dir.glob("*.jsonl")) if not jsonl_files: @@ -185,42 +427,53 @@ def main() -> int: logger.info("Found %d JSONL file(s)", len(jsonl_files)) - total_processed = 0 - total_extracted = 0 - all_errors: list[dict] = [] - + total = ProcessFileStats() for fpath in jsonl_files: out_path = output_dir / fpath.name - processed, extracted, errors = process_file(fpath, out_path, template, args.answer_prefix) - total_processed += processed - total_extracted += extracted - all_errors.extend(errors) - logger.info(" %s: %d records processed", fpath.name, processed) - - if all_errors: + stats = process_file( + fpath, + out_path, + template, + args.answer_prefix, + response_params, + date_resolver=date_resolver, + ) + total.processed += stats.processed + total.extracted += stats.extracted + total.date_hits += stats.date_hits + total.date_fallbacks += stats.date_fallbacks + total.errors.extend(stats.errors) + logger.info(" %s: %d records processed", fpath.name, stats.processed) + + if total.errors: errors_path = output_dir / "errors.jsonl" with open(errors_path, "w") as ef: - for err in all_errors: + for err in total.errors: ef.write(json.dumps(err) + "\n") - logger.warning("Errors: %d -> %s", len(all_errors), errors_path) + logger.warning("Errors: %d -> %s", len(total.errors), errors_path) logger.info("") logger.info("=" * 70) logger.info("SUMMARY") logger.info("=" * 70) - logger.info("Total processed: %d", total_processed) + logger.info("Total processed: %d", total.processed) if args.answer_prefix: + pct = total.extracted / total.processed * 100 if total.processed else 0.0 + logger.info("Answer extracted: %d (%.1f%%)", total.extracted, pct) + logger.info("Kept full text: %d (%.1f%%)", total.processed - total.extracted, 100.0 - pct) + if date_resolver is not None: + total_dates = total.date_hits + total.date_fallbacks + pct_hit = total.date_hits / total_dates * 100 if total_dates else 0.0 logger.info( - "Answer extracted: %d (%.1f%%)", - total_extracted, - total_extracted / total_processed * 100 if total_processed else 0, + "current_date resolved: %d / %d (%.2f%%)", total.date_hits, total_dates, pct_hit ) logger.info( - "Kept full text: %d (%.1f%%)", - total_processed - total_extracted, - (total_processed - total_extracted) / total_processed * 100 if total_processed else 0, + "current_date fallback: %d / %d (%.2f%%)", + total.date_fallbacks, + total_dates, + 100.0 - pct_hit, ) - logger.info("Errors: %d", len(all_errors)) + logger.info("Errors: %d", len(total.errors)) logger.info("Output: %s", output_dir) logger.info("=" * 70) diff --git a/nvflow/recipes/finance/utils/rl/regex_prefilter_questions.py b/nvflow/recipes/finance/utils/rl/regex_prefilter_questions.py new file mode 100644 index 0000000..214541b --- /dev/null +++ b/nvflow/recipes/finance/utils/rl/regex_prefilter_questions.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Phase 1 of the ``validate_questions`` GRPO stage: CPU-only regex prefilter. + +Deterministic, intentionally narrow drop rule -- recall over precision, so +the LLM phase (Phase 2) is what catches subtler cases. + +A record is dropped ONLY when ALL of the following hold: + +1. ``problem`` contains a vague-reference phrase (``the company``/``the firm``/ + ``this filing``/...) -- see ``VAGUE_REFS``. +2. ``problem`` does NOT mention ``record["company_name"]`` (full name + substring match, or first-token whole-word fallback). +3. ``problem`` has NO ticker-like uppercase token (``\\b[A-Z]{1,5}\\b``) + outside ``TICKER_DENYLIST`` of common non-ticker acronyms. +4. ``problem`` has NO mid-sentence proper-noun token outside + ``_PROPER_NOUN_STOPWORDS`` (rescues cases like ``company_name="ABNB"`` + but question text says ``"Airbnb"``). + +If any check (2-4) passes, the record is kept. + +Usage: + python regex_prefilter_questions.py \\ + --input_file .../final_result.jsonl \\ + --output_kept .../prefiltered.jsonl \\ + --output_dropped .../regex_dropped.jsonl \\ + --stats_file .../prefilter_stats.json +""" + +import argparse +import os +import re +from pathlib import Path + +import orjson + +from nvflow.utils import setup_logger + +logger = setup_logger(__name__) + +WRITE_BUFFER_SIZE = 1000 + +# Vague company references that indicate a question may not be self-contained. +# All compared case-insensitively against the ``problem`` text. +VAGUE_REFS = ( + "the company", + "the firm", + "the entity", + "the corporation", + "the business", + "the organization", + "this filing", + "this company", +) + +# Ticker-like uppercase tokens of 1-5 letters. A real company identifier +# if present (AAPL, NVDA, GOOGL, etc.). We exclude a small deny-list of +# sentence-start and common English acronyms that match the pattern but +# aren't tickers in context. +TICKER_RE = re.compile(r"\b[A-Z]{1,5}\b") + +# Tokens that LOOK like tickers but are common sentence-case words or +# well-known non-ticker acronyms -- used to avoid false "ticker present" +# matches on generic prose. Kept conservative so we err on the side of +# recognising a ticker (and therefore keeping the record). +TICKER_DENYLIST = frozenset( + { + "A", + "AI", + "AM", + "AN", + "AND", + "AS", + "AT", + "BE", + "BY", + "CEO", + "CFO", + "COO", + "CTO", + "DO", + "EPS", + "FOR", + "GDP", + "GO", + "IF", + "IN", + "INC", + "IS", + "IT", + "IT'S", + "ITS", + "LLC", + "LP", + "LTD", + "M", + "MD", + "MY", + "NO", + "NOT", + "OF", + "ON", + "OR", + "OUR", + "QA", + "QB", + "QC", + "QD", + "QE", + "R", + "SEC", + "SO", + "THE", + "TO", + "UP", + "US", + "USA", + "WE", + "WHY", + } +) + + +def _has_vague_reference(text: str) -> bool: + """True when ``text`` contains any vague-reference phrase (case-insensitive).""" + lowered = text.lower() + return any(ref in lowered for ref in VAGUE_REFS) + + +def _has_company_name(text: str, company_name: str) -> bool: + """True when ``text`` mentions ``company_name``. + + Matches in order of specificity: + 1. Full name as substring (case-insensitive). E.g. ``company_name`` + ``"NVIDIA Corporation"`` matches if the full string appears. + 2. First token of ``company_name`` as a whole word (case-insensitive). + E.g. ``company_name="Apple Inc."`` matches ``"Apple's 10-K"`` via the + first-token fallback because the SDG data often stores the corporate + suffix (``Inc.``, ``Corporation``, ...) while questions use the + short form. + + Only falls back to the first token when it is at least 3 characters long + and not a stopword, to avoid spurious matches on words like ``the`` + or single-letter corporate prefixes. + """ + if not company_name: + return False + + text_lower = text.lower() + name_lower = company_name.lower() + + if name_lower in text_lower: + return True + + tokens = company_name.split() + if not tokens: + return False + + first_token = tokens[0] + if len(first_token) < 3 or first_token.lower() in {"the", "a", "an"}: + return False + + pattern = r"\b" + re.escape(first_token) + r"\b" + return bool(re.search(pattern, text, flags=re.IGNORECASE)) + + +def _has_ticker(text: str) -> bool: + """True when ``text`` contains at least one ticker-like uppercase token + that is not in the deny-list of common non-ticker acronyms.""" + for token in TICKER_RE.findall(text): + if token not in TICKER_DENYLIST: + return True + return False + + +# Mid-sentence capitalised words that are NOT proper-noun signals. Used to +# stop ``_has_proper_noun_mid_sentence`` from treating sentence-start +# interrogatives and common English stopwords as implicit company references. +# +# Entries are stored in the exact case the regex will emit (title case, first +# char upper + rest lower), since the regex ``\b[A-Z][a-zA-Z]{2,}\b`` already +# requires the first character to be uppercase. No case normalisation happens +# at match time -- all-caps tokens like "HOW" are extremely rare in SEC text +# and would fall through to the "unknown proper noun" branch (i.e. kept). +_PROPER_NOUN_STOPWORDS = frozenset( + { + # WH / interrogative starters + "How", + "What", + "Why", + "Where", + "When", + "Who", + "Which", + "Whom", + "Whose", + # Imperative question starters + "Given", + "Considering", + "Assuming", + "Suppose", + "Compare", + "Contrast", + "Explain", + "Discuss", + "Describe", + "Analyse", + "Analyze", + "Evaluate", + "Identify", + "Summarise", + "Summarize", + "Define", + "Calculate", + "Estimate", + "Find", + "List", + "Name", + "Provide", + "Present", + "Show", + "State", + "Using", + # Auxiliary / modal verbs capitalised at sentence start + "Is", + "Are", + "Was", + "Were", + "Am", + "Be", + "Been", + "Does", + "Do", + "Did", + "Has", + "Have", + "Had", + "Can", + "Could", + "Should", + "Would", + "Will", + "Shall", + "May", + "Might", + "Must", + # Prepositions / conjunctions often capitalised after a period + "If", + "In", + "On", + "At", + "By", + "For", + "To", + "From", + "With", + "Of", + "And", + "Or", + "But", + "As", + "Than", + "Then", + "That", + "This", + "These", + "Those", + "Between", + "Among", + "Over", + "Under", + "During", + "Before", + "After", + "Based", + "Non", # e.g. "Non-GAAP" + # Generic sentence starters we've seen in SDG prompts + "The", + "A", + "An", + } +) + +# Matches a capitalised token of 3+ letters (including all-caps like "NVDA" +# since ``[a-zA-Z]`` matches uppercase too). Apostrophes terminate the +# match so "Airbnb's" captures "Airbnb". +_PROPER_NOUN_RE = re.compile(r"\b[A-Z][a-zA-Z]{2,}\b") + + +def _has_proper_noun_mid_sentence(text: str) -> bool: + """True when ``text`` has a capitalised proper-noun token not in the + sentence-starter stopword set. Used as a recall-over-precision + backstop when ``company_name`` is a ticker (e.g. ``"ABNB"``) but the + question uses the full company name (e.g. ``"Airbnb"``), so neither + ``_has_company_name`` nor ``_has_ticker`` catches the reference.""" + for token in _PROPER_NOUN_RE.findall(text): + if token not in _PROPER_NOUN_STOPWORDS: + return True + return False + + +def _should_drop(problem: str, company_name: str) -> tuple[bool, str]: + """Return (drop, reason). ``drop=True`` means the record should be dropped.""" + if not isinstance(problem, str) or not problem: + return True, "empty_problem" + + has_vague = _has_vague_reference(problem) + if not has_vague: + return False, "no_vague_reference" + + has_name = _has_company_name(problem, company_name) + if has_name: + return False, "vague_reference_but_company_named" + + has_tkr = _has_ticker(problem) + if has_tkr: + return False, "vague_reference_but_ticker_present" + + if _has_proper_noun_mid_sentence(problem): + return False, "vague_reference_but_proper_noun_present" + + return True, "vague_reference_no_identifier" + + +def prefilter( + input_file: str, + output_kept: str, + output_dropped: str, + stats_file: str, + high_drop_threshold: float = 0.20, +) -> dict: + """Run the Phase 1 regex prefilter. + + See module docstring for the rule. Returns the stats dict that was + written to ``stats_file``. + """ + num_total = 0 + num_kept = 0 + num_dropped = 0 + num_empty_problem = 0 + + kept_buffer: list[bytes] = [] + dropped_buffer: list[bytes] = [] + + with ( + open(input_file, "rb") as reader, + open(output_kept, "wb") as kept_writer, + open(output_dropped, "wb") as dropped_writer, + ): + for line in reader: + line = line.strip() + if not line: + continue + + num_total += 1 + + try: + row = orjson.loads(line) + except orjson.JSONDecodeError as exc: + # Malformed input -- conservatively drop with a reason so + # downstream can see what happened, but don't crash the job. + dropped_buffer.append( + orjson.dumps( + { + "_regex_drop_reason": "malformed_json", + "_regex_drop_error": str(exc), + "_regex_drop_raw": line.decode("utf-8", errors="replace")[:500], + } + ) + ) + num_dropped += 1 + continue + + problem = row.get("problem", "") + company_name = row.get("company_name", "") + + drop, reason = _should_drop(problem, company_name) + + if reason == "empty_problem": + num_empty_problem += 1 + + if drop: + num_dropped += 1 + dropped_row = dict(row) + dropped_row["_regex_drop_reason"] = reason + dropped_buffer.append(orjson.dumps(dropped_row)) + else: + num_kept += 1 + kept_buffer.append(orjson.dumps(row)) + + if len(kept_buffer) >= WRITE_BUFFER_SIZE: + kept_writer.write(b"\n".join(kept_buffer) + b"\n") + kept_buffer.clear() + if len(dropped_buffer) >= WRITE_BUFFER_SIZE: + dropped_writer.write(b"\n".join(dropped_buffer) + b"\n") + dropped_buffer.clear() + + if kept_buffer: + kept_writer.write(b"\n".join(kept_buffer) + b"\n") + if dropped_buffer: + dropped_writer.write(b"\n".join(dropped_buffer) + b"\n") + + drop_rate = num_dropped / num_total if num_total else 0.0 + high_drop_warning = drop_rate > high_drop_threshold + + stats = { + "num_total": num_total, + "num_kept": num_kept, + "num_dropped": num_dropped, + "num_empty_problem": num_empty_problem, + "drop_rate": round(drop_rate, 6), + "high_drop_threshold": high_drop_threshold, + "high_drop_warning": high_drop_warning, + "input_file": input_file, + "output_kept": output_kept, + "output_dropped": output_dropped, + } + + # Atomic write: the skip-if-present path at the CLI entrypoint treats + # stats_file's existence as "prior run completed", so the file must + # only be visible when fully written. A crash mid-write would + # otherwise leave a truncated stats_file and silently trigger a skip + # with stale audit data. + tmp_stats_file = f"{stats_file}.tmp" + with open(tmp_stats_file, "wb") as stats_writer: + stats_writer.write(orjson.dumps(stats, option=orjson.OPT_INDENT_2)) + os.replace(tmp_stats_file, stats_file) + + logger.info("regex prefilter summary") + logger.info(f" total: {num_total}") + logger.info(f" kept: {num_kept}") + logger.info(f" dropped: {num_dropped} ({drop_rate * 100:.2f}%)") + if num_empty_problem: + logger.info(f" empty_problem (dropped): {num_empty_problem}") + if high_drop_warning: + logger.warning( + "drop rate %.2f%% exceeds threshold %.2f%% -- inspect %s before " + "running downstream stages", + drop_rate * 100, + high_drop_threshold * 100, + output_dropped, + ) + + return stats + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Phase 1 regex prefilter for GRPO validate_questions stage" + ) + parser.add_argument( + "--input_file", + required=True, + help="Input JSONL produced by SDG (expects 'problem' and 'company_name' fields).", + ) + parser.add_argument( + "--output_kept", + required=True, + help="Output JSONL of records that passed the regex filter.", + ) + parser.add_argument( + "--output_dropped", + required=True, + help="Output JSONL of records dropped by the regex filter, annotated with _regex_drop_reason.", + ) + parser.add_argument( + "--stats_file", + required=True, + help="Output JSON file with total / kept / dropped counts and drop rate.", + ) + parser.add_argument( + "--high_drop_threshold", + type=float, + default=0.20, + help="Drop-rate above which the stats file records high_drop_warning=true (default 0.20).", + ) + parser.add_argument( + "--force", + action="store_true", + help="Force a re-run even if outputs are already on disk. Default " + "is skip-if-present: the script is a no-op when stats_file + " + "output_kept are both already present, which keeps phase 1 rerun-" + "safe (doesn't rewrite prefiltered.jsonl, so phase 2's resume by " + "row index in output.jsonl-async stays consistent).", + ) + args = parser.parse_args() + + if not args.force: + stats_path = Path(args.stats_file) + kept_path = Path(args.output_kept) + if stats_path.exists() and kept_path.exists(): + # Parse the cached stats and verify the prior run was against + # the same input we're being asked to filter. Guards against + # the footgun where upstream SDG swaps out final_result.jsonl + # but step-0-validate-questions/ artefacts from the previous + # source are left in place. + cached_input: str | None = None + try: + cached_stats = orjson.loads(stats_path.read_bytes()) + cached_input = cached_stats.get("input_file") + except orjson.JSONDecodeError as exc: + logger.warning( + "regex prefilter: stats_file %s is corrupt (%s); re-running", + stats_path, + exc, + ) + + if cached_input == args.input_file: + logger.info( + "regex prefilter skipped: outputs already present " + "(stats=%s, kept=%s; pass --force to re-run)", + stats_path, + kept_path, + ) + raise SystemExit(0) + + if cached_input is not None: + logger.warning( + "regex prefilter: stats_file %s was produced from input %r " + "but current invocation specifies %r; re-running", + stats_path, + cached_input, + args.input_file, + ) + + prefilter( + args.input_file, + args.output_kept, + args.output_dropped, + args.stats_file, + high_drop_threshold=args.high_drop_threshold, + ) diff --git a/nvflow/recipes/finance/utils/rl/responses_api_converter.py b/nvflow/recipes/finance/utils/rl/responses_api_converter.py index 03339fe..fb1da14 100644 --- a/nvflow/recipes/finance/utils/rl/responses_api_converter.py +++ b/nvflow/recipes/finance/utils/rl/responses_api_converter.py @@ -35,7 +35,6 @@ import argparse import json import sys -import uuid as uuid_mod from pathlib import Path from nvflow.utils import setup_logger @@ -48,6 +47,10 @@ def _convert_row(row: dict) -> dict: Requires ``prompt`` (model input), ``problem`` (raw question), and ``expected_answer`` (extracted clean answer). + + When ``_response_params`` is present (set by apply_prompt_template for + agent-style templates), its contents (tools, parallel_tool_calls, etc.) are + merged into ``responses_create_params``. """ prompt = row.get("prompt", "") if not prompt: @@ -58,11 +61,19 @@ def _convert_row(row: dict) -> dict: raise KeyError("'expected_answer' field is required (run apply_prompt_template first)") result = dict(row) - result["responses_create_params"] = {"input": [{"role": "user", "content": prompt}]} + rcp: dict = {"input": [{"role": "user", "content": prompt}]} + response_params = result.pop("_response_params", None) + if response_params: + rcp.update(response_params) + result["responses_create_params"] = rcp result["question"] = row.get("problem", "") result["expected_answer"] = expected_answer + # A random uuid4 fallback here would break aggregate_seeds (keyed on uuid). if "uuid" not in result: - result["uuid"] = str(uuid_mod.uuid4()) + raise KeyError( + "Record missing 'uuid' field -- upstream data_transformation " + "should have assigned one via uuid5(problem, generation)." + ) return result diff --git a/nvflow/recipes/finance/utils/rl/shuffle_jsonl.py b/nvflow/recipes/finance/utils/rl/shuffle_jsonl.py new file mode 100644 index 0000000..8bb5dfa --- /dev/null +++ b/nvflow/recipes/finance/utils/rl/shuffle_jsonl.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Deterministic in-place shuffle of a JSONL file. + +Used as a post-pass in ``prepare_data`` so that ``collect_rollouts`` +(which takes contiguous slices for ``num_chunks > 1`` and +``head -n N`` for ``max_num_samples``) sees a representative ordering +regardless of the per-filing clustering that SDG produces. + +The previous pre-rollout ``train_validation_split`` stage (now moved +post-rollout) used to shuffle implicitly; this script restores that +property for the rollout input without reintroducing a pre-rollout +split. + +Usage:: + + python -m nvflow.recipes.finance.utils.rl.shuffle_jsonl \\ + --input_file /path/to/train.jsonl \\ + --random_seed 42 +""" + +import argparse +import random +import sys +from pathlib import Path + +from nvflow.utils import setup_logger + +logger = setup_logger(__name__) + + +def shuffle_file(input_file: str, random_seed: int) -> int: + """Shuffle the JSONL file at ``input_file`` in place. + + Uses a deterministic seed so reruns produce the same order. Reads + the full file into memory (jsonl is line-oriented so this is safe + for multi-GB files on the prepare_data Slurm node). Preserves + blank lines at EOF by operating on raw ``readlines()`` bytes. + """ + path = Path(input_file) + if not path.exists(): + logger.error("Input file does not exist: %s", path) + sys.exit(1) + + with open(path, "rb") as f: + lines = f.readlines() + + rng = random.Random(random_seed) + rng.shuffle(lines) + + with open(path, "wb") as f: + f.write(b"".join(lines)) + + return len(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Deterministic in-place JSONL shuffle.") + parser.add_argument("--input_file", required=True, help="JSONL file to shuffle in place.") + parser.add_argument( + "--random_seed", + type=int, + default=42, + help="Seed for deterministic shuffle (default: 42, matches train_validation_split).", + ) + args = parser.parse_args() + + n = shuffle_file(args.input_file, args.random_seed) + logger.info("Shuffled %d rows with seed=%d -> %s", n, args.random_seed, args.input_file) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nvflow/recipes/finance/utils/sdg/document_grounded_preprocess.py b/nvflow/recipes/finance/utils/sdg/document_grounded_preprocess.py index c173c2f..b317cec 100644 --- a/nvflow/recipes/finance/utils/sdg/document_grounded_preprocess.py +++ b/nvflow/recipes/finance/utils/sdg/document_grounded_preprocess.py @@ -194,11 +194,14 @@ def construct_question_verify_input(input_dir: Path, output_file: Path): def _check_verification(result: dict[str, Any]) -> bool: """Check if a verification result indicates 'Yes' (verified).""" - generation = result.get("generation", "") + generation = result.get("generation") or "" if not generation: ser_out = result.get("serialized_output", []) if isinstance(ser_out, list) and len(ser_out) > 0: - generation = ser_out[0].get("content", "") + generation = ser_out[0].get("content") or "" + + if not generation: + return False if "<|channel|>final<|message|>" in generation: final_ans = generation.split("<|channel|>final<|message|>")[-1].strip() diff --git a/nvflow/recipes/finance/utils/sdg/prepare_genselect_data.py b/nvflow/recipes/finance/utils/sdg/prepare_genselect_data.py index 0673176..465bf34 100644 --- a/nvflow/recipes/finance/utils/sdg/prepare_genselect_data.py +++ b/nvflow/recipes/finance/utils/sdg/prepare_genselect_data.py @@ -99,6 +99,8 @@ def merge_jsonl_files(input_files, output_file): logger.info(f"\nPhase 2: Writing merged output to {output_file}...") logger.info(f"Total unique problems: {len(data)}") + os.makedirs(os.path.dirname(output_file), exist_ok=True) + # Write merged data with buffering buffer = [] diff --git a/nvflow/recipes/finance/utils/sft/messages_converter.py b/nvflow/recipes/finance/utils/sft/messages_converter.py index 1a1bf0f..9127d0e 100644 --- a/nvflow/recipes/finance/utils/sft/messages_converter.py +++ b/nvflow/recipes/finance/utils/sft/messages_converter.py @@ -98,11 +98,15 @@ def convert_record( output_text = record.get("output", "") uuid = record.get("uuid", "") - # Start with empty system message (required by OpenAI format) - messages: list[dict[str, str]] = [{"role": "system", "content": ""}] - - # Parse chat template to get user messages - messages.extend(parse_chat_template(input_text)) + # parse_chat_template returns the parsed sequence including any system + # block embedded in the input. Use it as-is when present; otherwise + # emit an empty system placeholder for OpenAI fine-tune compatibility. + parsed = parse_chat_template(input_text) + if parsed and parsed[0].get("role") == "system": + messages: list[dict[str, str]] = list(parsed) + else: + messages = [{"role": "system", "content": ""}] + messages.extend(parsed) # Build assistant message assistant_msg: dict[str, str] = {"role": "assistant"} @@ -185,6 +189,16 @@ def validate_record(record: dict[str, Any], line_num: int) -> list[str]: if "reasoning_content" in msg and role != "assistant": issues.append(f"Line {line_num}: {path}.reasoning_content only valid for assistant") + # Reject records with more than one system message (a past bug + # unconditionally prepended an empty system block before extending + # with parse_chat_template, which itself returns a system block for + # Qwen3 inputs). + system_count = sum(1 for m in messages if isinstance(m, dict) and m.get("role") == "system") + if system_count > 1: + issues.append( + f"Line {line_num}: record has {system_count} system messages; expected at most 1" + ) + # Check metadata metadata = record.get("metadata") if not isinstance(metadata, dict) or not metadata.get("uuid"): diff --git a/nvflow/recipes/finance/utils/shared/audit_duplicates.py b/nvflow/recipes/finance/utils/shared/audit_duplicates.py new file mode 100644 index 0000000..e0460b4 --- /dev/null +++ b/nvflow/recipes/finance/utils/shared/audit_duplicates.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Audit a JSONL dataset for duplicates. + +Reports three distinct notions of duplication, in increasing strictness: + +1. ``uuid`` duplicates -- records sharing the deterministic uuid assigned + by ``dataset_transformer.generate_uuid(problem, final_generation)``. + Equivalent to same ``(problem, final_generation)``. + +2. ``(problem, generation)`` duplicates -- the pair from which ``uuid`` + is derived; reported separately so this audit is useful on files + that don't yet have a uuid field (e.g. SDG raw output, the + prefiltered.jsonl inside validate_questions). + +3. Same-``problem``-different-``generation`` clusters -- the same user + question repeated with different candidate answers. Typically + indicates SDG over-generation or selection-index branching; not a + "duplicate" that can be dropped silently but useful to quantify. + +Read-only. Does not modify input. +""" + +import argparse +import json +import logging +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO, format="%(message)s") + + +def _jsonl_rows(path: Path): + """Yield rows from a JSONL file, skipping blank and malformed lines.""" + with open(path, encoding="utf-8") as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError as exc: + logger.warning(" skipped malformed JSON at %s:%d: %s", path, line_num, exc) + + +def audit(path: Path, problem_field: str, generation_field: str) -> dict[str, Any]: + """Compute duplicate statistics for *path* and log them. + + Returns the stats dict so callers can consume programmatically. + """ + n_total = 0 + n_with_uuid = 0 + uuid_counts: Counter = Counter() + pair_counts: Counter = Counter() + problem_to_generations: dict[str, set[str]] = defaultdict(set) + + for row in _jsonl_rows(path): + n_total += 1 + uid = row.get("uuid") + if uid: + n_with_uuid += 1 + uuid_counts[uid] += 1 + problem = row.get(problem_field, "") + generation = row.get(generation_field, "") + pair_counts[(problem, generation)] += 1 + if problem: + problem_to_generations[problem].add(generation) + + uuid_dup_records = sum(c - 1 for c in uuid_counts.values() if c > 1) + pair_dup_records = sum(c - 1 for c in pair_counts.values() if c > 1) + problems_with_variants = {p: gs for p, gs in problem_to_generations.items() if len(gs) > 1} + variant_record_count = sum(len(gs) for gs in problems_with_variants.values()) + + # Cluster-size histograms (top 5 most duplicated keys) + top_uuid_clusters = uuid_counts.most_common(5) + top_pair_clusters = pair_counts.most_common(5) + + stats = { + "path": str(path), + "total_records": n_total, + "records_with_uuid_field": n_with_uuid, + # uuid duplicates + "unique_uuids": len(uuid_counts), + "uuid_duplicate_records": uuid_dup_records, + "uuid_duplicate_pct": (uuid_dup_records / n_total * 100) if n_total else 0.0, + # (problem, generation) pair duplicates + "unique_problem_generation_pairs": len(pair_counts), + "pair_duplicate_records": pair_dup_records, + "pair_duplicate_pct": (pair_dup_records / n_total * 100) if n_total else 0.0, + # same-problem-different-generation clusters + "unique_problems": len(problem_to_generations), + "problems_with_multiple_generations": len(problems_with_variants), + "variant_record_count": variant_record_count, + "variant_record_pct": (variant_record_count / n_total * 100 if n_total else 0.0), + "top_uuid_clusters": top_uuid_clusters, + "top_pair_clusters_count_only": [c for _, c in top_pair_clusters], + } + + logger.info("") + logger.info("=" * 80) + logger.info("DUPLICATE AUDIT") + logger.info("=" * 80) + logger.info("Path: %s", path) + logger.info("Total records: %d", n_total) + logger.info("Records with uuid field: %d", n_with_uuid) + logger.info("") + logger.info("-- uuid duplicates --") + logger.info("Unique uuids: %d", len(uuid_counts)) + logger.info( + "Duplicate records: %d (%.2f%%)", + uuid_dup_records, + stats["uuid_duplicate_pct"], + ) + if top_uuid_clusters: + logger.info("Top uuid clusters (count x uuid):") + for uid, count in top_uuid_clusters: + if count > 1: + logger.info(" %5d %s", count, uid) + logger.info("") + logger.info("-- (problem, generation) duplicates --") + logger.info( + "Unique (problem, generation) pairs: %d", + len(pair_counts), + ) + logger.info( + "Duplicate records: %d (%.2f%%)", + pair_dup_records, + stats["pair_duplicate_pct"], + ) + logger.info("") + logger.info("-- same-problem-different-generation --") + logger.info("Unique problems: %d", len(problem_to_generations)) + logger.info( + "Problems with >1 generation: %d", + len(problems_with_variants), + ) + logger.info( + "Records in variant clusters: %d (%.2f%%)", + variant_record_count, + stats["variant_record_pct"], + ) + + return stats + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Audit a JSONL file for duplicate records", + ) + parser.add_argument("input_file", type=str, help="Path to input JSONL file") + parser.add_argument( + "--problem_field", + default="problem", + help='Field name for the "problem" content (default: problem)', + ) + parser.add_argument( + "--generation_field", + default="generation", + help='Field name for the "generation" content (default: generation; ' + 'use "answer" for raw SDG that has not been through dataset_transformer)', + ) + parser.add_argument( + "--json_output", + type=str, + default=None, + help="Optional path to write the stats dict as JSON (alongside stdout)", + ) + args = parser.parse_args() + + path = Path(args.input_file) + if not path.exists(): + raise SystemExit(f"Input file not found: {path}") + + stats = audit(path, args.problem_field, args.generation_field) + + if args.json_output: + out_path = Path(args.json_output) + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w") as f: + json.dump(stats, f, indent=2, default=str) + logger.info("\nStats JSON -> %s", out_path) diff --git a/nvflow/recipes/finance/utils/shared/dataset_splitter.py b/nvflow/recipes/finance/utils/shared/dataset_splitter.py index a876392..d1bf048 100644 --- a/nvflow/recipes/finance/utils/shared/dataset_splitter.py +++ b/nvflow/recipes/finance/utils/shared/dataset_splitter.py @@ -17,7 +17,7 @@ Memory-efficient streaming approach: 1. Pass 1: Count records per category, apply token length filter 2. Pass 2: Stream-write to train/val files -3. Shuffle output files +3. Shuffle or sort output files (curriculum ordering via --sort_by) """ import argparse @@ -45,6 +45,17 @@ def filter_record(record: dict, output_fields: list[str] | None = None) -> dict: return {k: record.get(k, 0 if k == "total_token_length" else "") for k in output_fields} +def _resolve_nested(record: dict, dotted_key: str): + """Traverse a nested dict via dot-notation (e.g. 'difficulty_profile.avg_reward').""" + obj = record + for part in dotted_key.split("."): + if isinstance(obj, dict): + obj = obj.get(part) + else: + return None + return obj + + def shuffle_file(filepath: Path, seed: int) -> None: """Shuffle a JSONL file in place.""" with open(filepath, encoding="utf-8") as f: @@ -54,6 +65,35 @@ def shuffle_file(filepath: Path, seed: int) -> None: f.writelines(line if line.endswith("\n") else line + "\n" for line in lines) +def sort_file(filepath: Path, sort_by: str, descending: bool = True) -> None: + """Sort a JSONL file in place by a (possibly nested) numeric field. + + Records missing the field are placed at the end regardless of sort order. + """ + with open(filepath, encoding="utf-8") as f: + lines = [line for line in f if line.strip()] + + sentinel = float("-inf") if descending else float("inf") + + def sort_key(line: str): + val = _resolve_nested(json.loads(line), sort_by) + return val if isinstance(val, int | float) else sentinel + + lines.sort(key=sort_key, reverse=descending) + + with open(filepath, "w", encoding="utf-8") as f: + f.writelines(line if line.endswith("\n") else line + "\n" for line in lines) + + n_missing = sum( + 1 + for line in lines + if not isinstance(_resolve_nested(json.loads(line), sort_by), int | float) + ) + logger.info(f"Sorted {len(lines):,} records by '{sort_by}' ({'desc' if descending else 'asc'})") + if n_missing: + logger.info(f" {n_missing:,} records missing '{sort_by}' placed at end") + + def perform_split( input_file: Path, output_dir: Path, @@ -62,6 +102,8 @@ def perform_split( seed: int, max_tokens: int | None = None, keep_all_fields: bool = False, + sort_by: str | None = None, + sort_order: str = "desc", ) -> tuple[int, int, int]: """Memory-efficient stratified split with optional token length filtering.""" train_path = output_dir / "train.jsonl" @@ -132,9 +174,13 @@ def keep(rec: dict) -> bool: f_train.write(out) train_count += 1 - # Shuffle output files - logger.info("Shuffling output files...") - shuffle_file(train_path, seed) + # Sort or shuffle output files + if sort_by: + logger.info(f"Sorting train file by '{sort_by}' ({sort_order})...") + sort_file(train_path, sort_by, descending=(sort_order == "desc")) + else: + logger.info("Shuffling output files...") + shuffle_file(train_path, seed) if val_count > 0: shuffle_file(val_path, seed + 1) @@ -154,6 +200,17 @@ def main(): action="store_true", help="Keep all input fields (GRPO). Default: keep only SFT fields.", ) + parser.add_argument( + "--sort_by", + help="Sort train file by this field instead of shuffling. " + "Supports dot-notation for nested fields (e.g. 'difficulty_profile.avg_reward').", + ) + parser.add_argument( + "--sort_order", + default="desc", + choices=["asc", "desc"], + help="Sort order when --sort_by is set (default: desc for easy-to-hard curriculum).", + ) args = parser.parse_args() logger.info("=" * 60) @@ -166,6 +223,8 @@ def main(): logger.info(f"Seed: {args.random_seed}") if args.max_token_length: logger.info(f"Max tokens: {args.max_token_length:,}") + if args.sort_by: + logger.info(f"Sort by: {args.sort_by} ({args.sort_order})") logger.info("") train_n, val_n, filtered_n = perform_split( @@ -176,6 +235,8 @@ def main(): args.random_seed, args.max_token_length, keep_all_fields=args.keep_all_fields, + sort_by=args.sort_by, + sort_order=args.sort_order, ) total = train_n + val_n diff --git a/nvflow/recipes/finance/utils/shared/dataset_transformer.py b/nvflow/recipes/finance/utils/shared/dataset_transformer.py index addd393..dcc83d4 100644 --- a/nvflow/recipes/finance/utils/shared/dataset_transformer.py +++ b/nvflow/recipes/finance/utils/shared/dataset_transformer.py @@ -91,9 +91,9 @@ def transform_record( if not context: return None, "Missing required field: context" - raw_generation = record.get("generation") + raw_generation = record.get("generation") or record.get("answer") if not raw_generation: - return None, "Missing required field: generation" + return None, "Missing required field: generation (or answer)" # Reasoning field (for separated format) if source_format == "separated": @@ -317,6 +317,12 @@ def main(): default="none", help="Output format: 'thinking' (Qwen3 tags), 'natural' (reasoning + answer), 'none' (answer only)", ) + parser.add_argument( + "--deduplicate_by_uuid", + action="store_true", + help="Drop exact (problem, generation) dups after outlier filter. " + "Opt-in; defaults to OFF to match legacy behavior (SFT + pre-dedup GRPO runs).", + ) args = parser.parse_args() @@ -449,6 +455,44 @@ def main(): # No filtering - use all transformed records final_records = transformed_records + # Opt-in dedup by uuid (itself = SHA-1(problem, final_generation)). + num_dedup_dropped = 0 + dedup_dropped_records: list[dict[str, Any]] = [] + if args.deduplicate_by_uuid and final_records: + logger.info("") + logger.info("=" * 80) + logger.info("DEDUPLICATION BY UUID") + logger.info("=" * 80) + seen_uuids: set[str] = set() + deduped: list[dict[str, Any]] = [] + for record in final_records: + uid = record.get("uuid") + if uid and uid in seen_uuids: + dedup_dropped_records.append(record) + continue + if uid: + seen_uuids.add(uid) + deduped.append(record) + + num_dedup_dropped = len(final_records) - len(deduped) + pct_dropped = num_dedup_dropped / len(final_records) * 100 + logger.info(f"Input records: {len(final_records):,}") + logger.info(f"Unique uuids: {len(deduped):,}") + logger.info(f"Duplicates dropped: {num_dedup_dropped:,} ({pct_dropped:.2f}%)") + if pct_dropped > 10: + logger.warning( + "Duplicate rate %.2f%% exceeds 10%%; investigate upstream SDG.", + pct_dropped, + ) + final_records = deduped + + if dedup_dropped_records: + dedup_path = output_path.parent / "duplicates.jsonl" + with open(dedup_path, "w", encoding="utf-8") as dedup_file: + for record in dedup_dropped_records: + dedup_file.write(json.dumps(record, ensure_ascii=False) + "\n") + logger.info(f"Dropped duplicates saved to: {dedup_path}") + # Write final records to chunks directory (always use chunking structure) num_chunks = args.num_chunks chunks_dir = output_path.parent / "chunks" @@ -493,7 +537,9 @@ def main(): logger.info(f"Errors encountered: {len(error_records)}") if args.filter_outliers: logger.info(f"Outliers filtered: {len(filtered_records)}") - logger.info(f"Final records: {len(final_records)}") + if args.deduplicate_by_uuid: + logger.info(f"Duplicates (uuid) dropped: {num_dedup_dropped}") + logger.info(f"Final records: {len(final_records)}") # Statistics on final records (after filtering if enabled) if final_records: @@ -535,7 +581,19 @@ def main(): logger.info(f"Output: {output_path.parent}/chunks/ ({num_chunks} chunks)") logger.info("=" * 80) - return 1 if error_records else 0 + if error_records: + error_rate = len(error_records) / total_records if total_records > 0 else 1.0 + if error_rate > 0.05: + logger.error( + f"Error rate {error_rate:.1%} ({len(error_records)}/{total_records}) " + f"exceeds 5% threshold" + ) + return 1 + logger.warning( + f"{len(error_records)} error(s) ({error_rate:.2%} of {total_records}) " + f"— below 5% threshold, treating as success" + ) + return 0 if __name__ == "__main__": diff --git a/nvflow/recipes/finance/utils/shared/question_context_utils.py b/nvflow/recipes/finance/utils/shared/question_context_utils.py index a6b2818..9151495 100644 --- a/nvflow/recipes/finance/utils/shared/question_context_utils.py +++ b/nvflow/recipes/finance/utils/shared/question_context_utils.py @@ -456,6 +456,8 @@ def map_questions_to_context( failed_count = 0 checkpoint_buffer = [] + os.makedirs(os.path.dirname(output_file), exist_ok=True) + with jsonlines.open(output_file, mode="a") as writer: with ProcessPoolExecutor(max_workers=num_workers) as executor: # Submit each chunk to a worker diff --git a/nvflow/recipes/finance/workflows/download_sec_filings.yaml b/nvflow/recipes/finance/workflows/download_sec_filings.yaml index 3ab52c8..42a31f8 100644 --- a/nvflow/recipes/finance/workflows/download_sec_filings.yaml +++ b/nvflow/recipes/finance/workflows/download_sec_filings.yaml @@ -13,11 +13,26 @@ cluster: my_cluster output_dir: /workspace/outputs/finance pipeline_stages: + - smoke - demo - sap-500 stages: + # Smoke stage: 2 companies, 1 year for pipeline smoke testing + smoke: + output_dir: ${output_dir}/smoke/workflow-2-download-sec/step-0-download + config: nvflow/recipes/finance/configs/smoke.yaml + sec_identity_email: your.email@email.com # UPDATE: Your email (required by SEC) + sec_identity_company: YourCompany # UPDATE: Your company name + dependencies: [] + stage_kwargs: + installation_command: >- + pip install -q --root-user-action=ignore + edgartools==5.20.2 sec-parser pandas pyarrow tqdm httpx tzdata && + ln -sfn $(python -c "import tzdata; print(tzdata.__path__[0])")/zoneinfo + /usr/share/zoneinfo 2>/dev/null || true + # Demo stage: 7 companies for quick testing demo: output_dir: ${output_dir}/demo/workflow-2-download-sec/step-0-download @@ -29,8 +44,8 @@ stages: installation_command: >- pip install -q --root-user-action=ignore edgartools==5.20.2 sec-parser pandas pyarrow tqdm httpx tzdata && - ln -sfn $(python -c "import tzdata; print(tzdata.__path__[0])")/zoneinfo - /usr/share/zoneinfo 2>/dev/null || true + rm -rf /usr/share/zoneinfo && + ln -s $(python -c "import tzdata; print(tzdata.__path__[0])")/zoneinfo /usr/share/zoneinfo # Production stage: Full S&P 500 sap-500: @@ -43,5 +58,5 @@ stages: installation_command: >- pip install -q --root-user-action=ignore edgartools==5.20.2 sec-parser pandas pyarrow tqdm httpx tzdata && - ln -sfn $(python -c "import tzdata; print(tzdata.__path__[0])")/zoneinfo - /usr/share/zoneinfo 2>/dev/null || true + rm -rf /usr/share/zoneinfo && + ln -s $(python -c "import tzdata; print(tzdata.__path__[0])")/zoneinfo /usr/share/zoneinfo diff --git a/nvflow/recipes/finance/workflows/eval/base.yaml b/nvflow/recipes/finance/workflows/eval/base.yaml index 455ca5a..44a6c17 100644 --- a/nvflow/recipes/finance/workflows/eval/base.yaml +++ b/nvflow/recipes/finance/workflows/eval/base.yaml @@ -57,15 +57,18 @@ benchmarks: seeds: 5 financebench: seeds: 5 - finance_agent: - seeds: 5 - judge: *judge_finance_strict - installation_command: "pip install -q --ignore-requires-python --force-reinstall model-library==0.1.8 && pip install -q func-timeout 'backoff>=2.2.1' 'tavily==1.1.0' 'compute-eval @ git+https://github.com/NVIDIA/compute-eval.git@2d14770'" - extra_args: >- - ++max_turns=50 - ++inference.tokens_to_generate=32000 - ++inference.temperature=0.0 - ++max_concurrent_requests=1 + # finance_agent: disabled until multi-turn tool-calling eval is fully validated. + # Re-enable when ready by uncommenting below: + # finance_agent: + # seeds: 5 + # judge: *judge_finance_strict + # installation_command: "pip install -q --ignore-requires-python --force-reinstall model-library==0.1.8 && pip install -q func-timeout 'backoff>=2.2.1' 'tavily==1.1.0' 'compute-eval @ git+https://github.com/NVIDIA/compute-eval.git@2d14770'" + # extra_args: >- + # ++max_turns=50 + # ++inference.tokens_to_generate=32000 + # ++inference.temperature=0.0 + # ++max_concurrent_requests=1 + finance_agent: null # ============================================================================ # Shared Settings @@ -73,10 +76,8 @@ benchmarks: datasets_dir: /workspace/nvflow/recipes/finance/datasets -conversion: - num_gpus: 8 - # Workaround for nemo-rl 0.7.1 bug - remove when container is updated - installation_command: "sed -i 's/hf_overrides=hf_overrides,//' /nemo_run/code/nemo_skills/training/nemo_rl/convert_megatron_to_hf.py" +# num_gpus defaults to cluster's gpus_per_node at runtime (portable across clusters) +conversion: {} # ============================================================================ # Prepare Data Stage (used by baselines.yaml and shared reference) @@ -84,5 +85,7 @@ conversion: stages: prepare_data: - dataset_names: [secque, financebench, finance_agent] + # finance_agent dataset prep disabled (benchmark disabled above). + # Re-add finance_agent to this list when the benchmark is re-enabled. + dataset_names: [secque, financebench] output_dir: /workspace/nvflow/recipes/finance/datasets diff --git a/nvflow/recipes/finance/workflows/eval/demo.yaml b/nvflow/recipes/finance/workflows/eval/demo.yaml index 9516c5c..4c3b885 100644 --- a/nvflow/recipes/finance/workflows/eval/demo.yaml +++ b/nvflow/recipes/finance/workflows/eval/demo.yaml @@ -25,13 +25,12 @@ pipeline_stages: - gemma-3-4b-it - gpt-oss-20b -# Demo uses only SecQUE and FinanceBench (skip finance_agent to save time) +# Demo uses only SecQUE and FinanceBench benchmarks: secque: seeds: 5 financebench: seeds: 5 - finance_agent: null stages: prepare_data: @@ -50,8 +49,8 @@ models: ++inference.top_k=20 ++inference.tokens_to_generate=16384 ++chat_template_kwargs.enable_thinking=true - server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3" - gpus: 1 + server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3 --tensor-parallel-size 2" + gpus: 4 nodes: 1 gemma-3-4b-it: @@ -62,8 +61,8 @@ models: inference_args: >- ++prompt_config=/workspace/nvflow/recipes/finance/prompts/secque_template.yaml ++inference.tokens_to_generate=8192 - server_args: "--max-model-len 40960 --async-scheduling" - gpus: 1 + server_args: "--max-model-len 40960 --async-scheduling --tensor-parallel-size 2" + gpus: 4 nodes: 1 gpt-oss-20b: @@ -74,6 +73,6 @@ models: ++prompt_config=/workspace/nvflow/recipes/finance/prompts/secque_template.yaml ++inference.tokens_to_generate=32768 ++chat_template_kwargs.reasoning_effort=high - server_args: "--max-model-len 65536 --async-scheduling" - gpus: 1 + server_args: "--max-model-len 65536 --async-scheduling --tensor-parallel-size 4" + gpus: 4 nodes: 1 diff --git a/nvflow/recipes/finance/workflows/eval/smoke.yaml b/nvflow/recipes/finance/workflows/eval/smoke.yaml new file mode 100644 index 0000000..3b17554 --- /dev/null +++ b/nvflow/recipes/finance/workflows/eval/smoke.yaml @@ -0,0 +1,50 @@ +# ============================================================================ +# Baseline Evaluation - Smoke Test Configuration +# ============================================================================ +# Minimal baseline eval: Qwen3-4B only, SecQUE only, 1 seed. +# +# Usage: +# uv run nflow run-all --config nvflow/recipes/finance/workflows/eval/smoke.yaml +# ============================================================================ + +_base_: base.yaml + +recipe: finance +workflow: + name: eval + type: "evaluation" + description: "Smoke test baseline evaluation (Qwen3-4B, SecQUE only)" + +cluster: my_cluster +base_output_dir: /workspace/outputs/finance/smoke/workflow-1-baseline-eval + +pipeline_stages: + - prepare_data + - qwen3-4b + +benchmarks: + secque: + seeds: 1 + financebench: null + finance_agent: null + +stages: + prepare_data: + dataset_names: [secque] + output_dir: /workspace/nvflow/recipes/finance/datasets + +models: + qwen3-4b: + path: /hf_models/Qwen/Qwen3-4B + server_type: vllm + dependencies: [prepare_data] + inference_args: >- + ++prompt_config=/workspace/nvflow/recipes/finance/prompts/secque_template.yaml + ++inference.temperature=0.6 + ++inference.top_p=0.95 + ++inference.top_k=20 + ++inference.tokens_to_generate=16384 + ++chat_template_kwargs.enable_thinking=true + server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3" + gpus: 1 + nodes: 1 diff --git a/nvflow/recipes/finance/workflows/grpo/base.yaml b/nvflow/recipes/finance/workflows/grpo/base.yaml index 4fc191e..9667f5d 100644 --- a/nvflow/recipes/finance/workflows/grpo/base.yaml +++ b/nvflow/recipes/finance/workflows/grpo/base.yaml @@ -17,9 +17,14 @@ # 4. See qwen3_4b.yaml for reference # # Environment Configuration: -# The NeMo-Gym environment is configurable via overrides.env.nemo_gym.config_paths. -# Default: equivalence_llm_judge (LLM-as-judge for semantic equivalence). -# To change environment, swap the config_paths entries in your model config. +# NeMo-Gym environments are defined in the top-level `environments` dict. +# Each environment has its own data pipeline and NeMo-Gym config. +# Use --environment to run a single environment. +# +# NeMo-RL / NeMo-Gym versions (pinned for reproducibility): +# NeMo-RL main: e5a729cc438ea71bafa7138204f861196598a9b2 +# NeMo-Gym feat/finance-sec-search-improvements: f24573bb157a1b4799591df1087d6db0b25e4e4c +# Mounted as overlay via my_cluster.yaml at /opt/NeMo-RL. # ============================================================================ # Recipe identifier (defines which recipe this workflow belongs to) @@ -32,55 +37,183 @@ workflow: description: "GRPO RL training for financial reasoning with NeMo-Gym" # Cluster to run jobs on (references cluster_configs/.yaml) -# Uses the same cluster config as SFT -- no NeMo-RL source mount needed. -# The container already has /opt/NeMo-RL at the correct commit. +# Uses my_cluster which mounts NeMo-RL + Gym overlays at /opt/NeMo-RL. cluster: my_cluster -# Base directory for all pipeline outputs -# Override in model configs: base_output_dir: /workspace/outputs/finance//workflow-5-grpo/ -base_output_dir: /workspace/outputs/finance/demo/workflow-5-grpo +# Root directory for all finance pipeline outputs (shared across workflows). +# REQUIRED -- override in model/run configs. No default; forces explicit choice. +# demo: /workspace/outputs/finance/demo +# sap-500: /workspace/outputs/finance/sap-500 +data_root: ??? + +# Base directory for shared data pipeline outputs (stages 0-4). +# Model-agnostic: identical regardless of which policy model is trained. +base_output_dir: ${data_root}/workflow-5-grpo + +# Model-specific output directory (stages 5-9: rollouts, compute_rewards, +# train_validation_split, training, eval). +# Override in model configs: model_output_dir: ${base_output_dir}/ +model_output_dir: ${base_output_dir} # ============================================================================ # Pipeline Stages - Execution Order # ============================================================================ -# Stage 0: data_transformation - SDG cleanup → model-agnostic schema (shared, CPU) -# Stage 1: apply_prompt_template - Apply prompt template + extract expected answer (CPU) -# Stage 2: convert_to_responses_api - Convert to NeMo-Gym Responses API format (CPU) -# Stage 3: train_validation_split - Split into train/val sets (shared, CPU) +# Stage 0: validate_questions - Drop structurally-broken SDG questions (GPU: GPT-OSS-120B) +# Stage 1: data_transformation - SDG cleanup → model-agnostic schema (shared, CPU) +# Stage 2: apply_prompt_template - Apply prompt template + extract expected answer (CPU) +# Stage 3: convert_to_responses_api - Convert to NeMo-Gym Responses API format (CPU) # Stage 4: prepare_data - Add agent_ref routing fields via ng_prepare_data (CPU) +# prefetch_cache - OPTIONAL: pre-warm SEC metadata cache (no step-N; SEC cache) # Stage 5: collect_rollouts - Rollout collection + reward profiling + filter (GPU) # Stage 6: compute_rewards - OPTIONAL: re-judge rollouts with different judge -# Stage 7: training - GRPO training with NeMo-Gym environment (GPU) +# Stage 7: train_validation_split - Final train/val split on reward-filtered data (CPU) +# Stage 8: training - GRPO training with NeMo-Gym environment (GPU) +# Stage 9: eval - Evaluate checkpoints on finance benchmarks +# +# validate_questions is a GRPO-only upstream data-quality filter. It reads +# the original SDG final_result.jsonl (via stages.validate_questions.source_data) +# and writes a filtered version to directories.step-0-validate-questions. Model +# configs should re-point env.raw_train_data to that stage's output so +# data_transformation automatically consumes the filtered data via the normal +# env.raw_train_data path (no Python change to data_transformation itself). # # collect_rollouts includes sub-jobs: aggregate (difficulty.jsonl) + filter. -# The filter sub-job produces train.jsonl / validation.jsonl at the stage -# output directory root. Training consumes these directly. +# The filter sub-job produces train.jsonl at the stage output directory root. # compute_rewards is optional -- uncomment to enable (also has filter sub-job). +# +# train_validation_split runs LAST before training so it always operates on +# the most recent filtered output -- collect_rollouts by default, or +# compute_rewards when the latter is enabled (model configs override +# train_validation_split.input_dir + dependencies in that case). pipeline_stages: - - data_transformation # Step 0: SDG cleanup (shared) - - apply_prompt_template # Step 1: Apply prompt template + extract answer (CPU) - - convert_to_responses_api # Step 2: Convert to Responses API format (CPU) - - train_validation_split # Step 3: Split into train/val sets (shared) + - validate_questions # Step 0: Drop structurally-broken questions before data_transformation + - data_transformation # Step 1: SDG cleanup (shared) + - apply_prompt_template # Step 2: Apply prompt template + extract answer (CPU) + - convert_to_responses_api # Step 3: Convert to Responses API format (CPU) - prepare_data # Step 4: Run ng_prepare_data (CPU) + - prefetch_cache # Pre-warm SEC metadata cache (CPU, optional, no step-N) - collect_rollouts # Step 5: Rollout collection + reward profiling + filter # - compute_rewards # Step 6: Re-judge rollouts with different judge (optional) - - training # Step 7: GRPO training with NeMo-Gym environment - - eval # Step 8: Evaluate checkpoints on finance benchmarks + - train_validation_split # Step 7: Final train/val split on reward-filtered data + - training # Step 8: GRPO training with NeMo-Gym environment + - eval # Step 9: Evaluate checkpoints on finance benchmarks # ============================================================================ # Directory Structure # ============================================================================ +# Every stage writes under ${base_output_dir} (workflow-5-grpo/), using the +# step-N- pattern. cache-finance-sec-search (SEC.gov metadata + +# parsed filing text) is workflow-5-grpo scoped -- it is populated by the +# prefetch_cache stage, consumed by collect_rollouts/compute_rewards, and +# has no readers outside this workflow. If you rename workflow-5-grpo and +# want to carry the cache forward, cp -r it into the new dir (or reuse the +# rename tree). directories: - raw_train_data: /workspace/outputs/finance/sap-500/workflow-3-template-based-sdg/step-5-filter-answers # Raw SDG data - step-0-data-transformation: ${base_output_dir}/step-0-data-transformation - step-1-apply-prompt-template: ${base_output_dir}/step-1-apply-prompt-template - step-2-convert-to-responses-api: ${base_output_dir}/step-2-convert-to-responses-api - step-3-train-validation-split: ${base_output_dir}/step-3-train-validation-split + # Model-agnostic data pipeline stages (0-4): written once, reused across models. + step-0-validate-questions: ${base_output_dir}/step-0-validate-questions + step-1-data-transformation: ${base_output_dir}/step-1-data-transformation + step-2-apply-prompt-template: ${base_output_dir}/step-2-apply-prompt-template + step-3-convert-to-responses-api: ${base_output_dir}/step-3-convert-to-responses-api step-4-prepare-data: ${base_output_dir}/step-4-prepare-data - step-5-collect-rollouts: ${base_output_dir}/step-5-collect-rollouts - step-6-compute-rewards: ${base_output_dir}/step-6-compute-rewards - step-7-training: ${base_output_dir}/step-7-training - step-8-eval: ${base_output_dir}/step-8-eval + # SEC.gov cache (metadata + parsed filing text). Lives inside workflow-5-grpo + # since it is GRPO-scoped; matches the path hardcoded in + # overlays/finance_sec_search_{sap500,demo}.yaml. + cache-finance-sec-search: ${base_output_dir}/cache/finance_sec_search + # Model-specific stages (rollouts, training, eval). + step-5-collect-rollouts: ${model_output_dir}/step-5-collect-rollouts + step-6-compute-rewards: ${model_output_dir}/step-6-compute-rewards + step-7-train-validation-split: ${model_output_dir}/step-7-train-validation-split + step-8-training: ${model_output_dir}/step-8-training + step-9-eval: ${model_output_dir}/step-9-eval + +# ============================================================================ +# Environment Configuration +# ============================================================================ +# Each entry defines a fully independent data pipeline + NeMo-Gym environment. +# Every stage runs per-environment, producing output in {step_dir}/{env_name}/. +# +# Data pipeline settings (stages 0-4): +# raw_train_data: Path to raw SDG data for this environment. +# source_format, reasoning_mode: Data transformation settings. +# prompt_template: YAML prompt template for apply_prompt_template (stage 2). +# answer_prefix: Optional answer extraction prefix for apply_prompt_template (stage 2). +# +# NeMo-Gym settings (stages 5-8): +# config_paths: NeMo-Gym YAML configs (WITHOUT vLLM model configs). +# agent_name: NeMo-Gym agent name for this environment. +# policy_vllm: (optional) Per-environment policy vLLM overrides (e.g. max_model_len). +# Merged onto the shared rollout.policy_vllm for rollout jobs. +# training_policy: (optional) Per-environment training policy overrides. +# Applied only for single-environment training (--environment ). +# Ignored for combined (multi-environment) training, which uses the +# model-level default. Maps to fields under `policy` in the NeMo-RL +# config (e.g. max_total_sequence_length, generation.vllm_cfg). +# judge_vllm: (optional) Per-environment judge vLLM config. Omit if not needed. +# +# Use --environment to run a single environment, or omit to run all. +# For training (step 9), omitting --environment merges all environments. +# Per-environment training_repeat > 1 oversamples that dataset in combined training. +environments: + equivalence_llm_judge: + # Data pipeline (stages 0-4) + raw_train_data: /workspace/outputs/finance/sap-500/workflow-3-template-based-sdg/step-5-filter-answers + source_format: separated + reasoning_mode: none + prompt_template: /workspace/nvflow/recipes/finance/prompts/secque_template.yaml + answer_prefix: "Answer:" + + # NeMo-Gym (stages 5-8) + config_paths: + - resources_servers/equivalence_llm_judge/configs/equivalence_llm_judge.yaml + - /workspace/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml + agent_name: equivalence_llm_judge_simple_agent + judge_vllm: + num_gpus: 0 # requires judge -- override in model config + + mcqa: + # Data pipeline (stages 0-4) -- placeholder: update raw_train_data when ready + raw_train_data: null + source_format: separated + reasoning_mode: none + prompt_template: null + answer_prefix: null + + # NeMo-Gym (stages 5-8) + config_paths: + - resources_servers/mcqa/configs/mcqa.yaml + agent_name: mcqa_simple_agent + + finance_sec_search: + # Data pipeline (stages 0-4) -- SDG data converted through all stages. + # The agent template includes tool definitions and sampling params that + # are carried through to responses_create_params by stages 2-3. + raw_train_data: /workspace/outputs/finance/sap-500/workflow-3-template-based-sdg/step-5-filter-answers + source_format: separated + reasoning_mode: none + prompt_template: /workspace/nvflow/recipes/finance/prompts/finance_sec_search_template_without_web.yaml + answer_prefix: null + + # NeMo-Gym (stages 5-8) + # Top-level config key differs from env dict key (upstream convention). + resources_server_name: finance_sec_search_resources_server + config_paths: + - resources_servers/finance_sec_search/configs/finance_sec_search.yaml + - /workspace/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_env.yaml + agent_name: finance_agent + agent_type: finance_agent + judge_vllm: + num_gpus: 0 # requires judge -- override in model config + responses_create_params: + max_output_tokens: 32768 # Cap multi-turn tool-calling generation + + # Pre-fetch SEC filing metadata cache before rollout (CPU-only). + # Idempotent: skips companies already cached. + # Set force: true to re-fetch all (e.g. after adding pagination support). + prefetch: + script: "resources_servers/finance_sec_search/scripts/prefetch_sec_metadata.py" + ticker_config: /workspace/nvflow/recipes/finance/configs/sp500.yaml + cache_dir: ${directories.cache-finance-sec-search} + force: false # ============================================================================ # Stage Configurations @@ -89,30 +222,76 @@ directories: stages: # -------------------------------------------------------------------------- - # Stage 0: Data Transformation (shared: SFT + GRPO) [CPU-only] + # Stage 0: Validate Questions (GRPO-only data quality pre-filter) [GPU] + # -------------------------------------------------------------------------- + # Drops SDG-generated questions that are structurally unusable as + # standalone questions (e.g. "the company" references with no company + # named anywhere). Recall over precision: only clearly broken questions + # are dropped; everything else is kept and flows through to + # data_transformation. + # + # Two-phase: (1) cheap regex prefilter for "the company"+no-identifier + # cases, (2) GPT-OSS-120B LLM classifier for subtler cases. + # + # Model configs must supply source_data (raw SDG dir) and override the nulls + # below. stage_kwargs is passed to nemo_skills.pipeline.cli.generate(), so + # keys must match its signature -- do NOT spread the _judge_vllm anchor here. + # Model configs must also re-point env.raw_train_data at + # ${directories.step-0-validate-questions}/. + validate_questions: + source_data: null # REQUIRED override in model config (e.g. *dg_sdg_data) + output_dir: ${directories.step-0-validate-questions} + environments: ${environments} + prompt_config: /workspace/nvflow/recipes/finance/prompts/validate_questions.yaml + stage_kwargs: + model: null # REQUIRED override in model config + server_type: vllm + server_gpus: null # REQUIRED override in model config + server_nodes: 1 + num_chunks: 8 + server_args: "" # override for --max-model-len / --reasoning-parser / --async-scheduling etc. + # gpt-oss-120b sampling mirrors the GRPO reward judge config. Client + # concurrency kept at 2048 -- above this the openai SDK's httpx pool + # exhausts under sustained load (observed client-side close at 75% + # completion on the 4096 production run). skip_filled enables + # rerun-safe resume from output_file-async. + inline_args: >- + ++chat_template_kwargs.reasoning_effort=high + ++inference.tokens_to_generate=8192 + ++max_concurrent_requests=2048 + ++skip_filled=True + + # -------------------------------------------------------------------------- + # Stage 1: Data Transformation (shared: SFT + GRPO) [CPU-only] # -------------------------------------------------------------------------- # Normalises raw SDG dataset to model-agnostic schema: # problem, context, reasoning_content, generation, uuid, question_type # # Override in model configs: input_files, source_format, reasoning_mode. # Default: raw SDG data from template-based-sdg pipeline. + # + # GRPO workflows list validate_questions before data_transformation so + # that env.raw_train_data (re-pointed at step-0-validate-questions in + # model configs) contains the filtered dataset by the time this stage runs. data_transformation: - input_files: - - ${directories.raw_train_data}/final_result.jsonl - output_file: ${directories.step-0-data-transformation}/final_result.jsonl - output_dir: ${directories.step-0-data-transformation} + output_dir: ${directories.step-1-data-transformation} + environments: ${environments} num_chunks: 10 - source_format: separated - reasoning_mode: none - filter_outliers: true - filter_config: - context_min_percentile: 0.5 - context_max_percentile: 100 - reasoning_min_percentile: 0.25 - reasoning_max_percentile: 99.5 + # filter_outliers disabled for GRPO: dataset_transformer filters on + # context + reasoning_content only, but the GRPO prompt template omits + # {context} and training runs with reasoning_mode=none -- neither field + # reaches the policy or the trainer. Observed drops on a 187K run also + # biased 3-6x toward Ratio_Analysis and Comparison_and_Trend_Analysis + # (the hardest reasoning types). Degenerate records are caught + # downstream by collect_rollouts' min_reward_std filter. + # SFT keeps filter_outliers=true via its own config. + filter_outliers: false + deduplicate_by_uuid: true + dependencies: + - validate_questions # -------------------------------------------------------------------------- - # Stage 1: Apply Prompt Template [CPU-only] + # Stage 2: Apply Prompt Template [CPU-only] # -------------------------------------------------------------------------- # Formats the problem field using a prompt template (merging instruction + # context + question) so the model receives the same structured prompt it @@ -122,164 +301,197 @@ stages: # To use a different prompt: override prompt_template in model configs. # To disable answer extraction: set answer_prefix to null. apply_prompt_template: - input_dir: ${directories.step-0-data-transformation}/chunks - output_dir: ${directories.step-1-apply-prompt-template} - prompt_template: /workspace/nvflow/recipes/finance/prompts/secque_template.yaml - answer_prefix: "Answer:" + input_dir: ${directories.step-1-data-transformation} + output_dir: ${directories.step-2-apply-prompt-template} + environments: ${environments} + + # Dynamic current_date per record. The rendered prompt's "as of X" + # anchor becomes the record's SDG source filing_date + a deterministic + # jitter of jitter_min_days..jitter_max_days (seeded by record.uuid). + # Matches how the eval benchmark uses an "as of" date as the filing + # anchor for questions that don't carry an explicit year. Set + # sec_metadata_parquet to null / omit to disable (template reverts to + # the fallback_current_date uniformly). + sec_metadata_parquet: ${data_root}/workflow-2-download-sec/step-0-download/sec_metadata.parquet + raw_sdg_filename: final_result.jsonl + jitter_min_days: 1 + jitter_max_days: 60 + fallback_current_date: "2025-04-07" + parquet_accession_column: accession_number + parquet_filing_date_column: filing_date dependencies: - data_transformation # -------------------------------------------------------------------------- - # Stage 2: Convert to Responses API Format [CPU-only] + # Stage 3: Convert to Responses API Format [CPU-only] # -------------------------------------------------------------------------- # Converts prompted data to NeMo-Gym Responses API format # (responses_create_params). Lossless: all original fields are preserved. # # Input can be a single JSONL file or a directory. convert_to_responses_api: - input_path: ${directories.step-1-apply-prompt-template} - output_dir: ${directories.step-2-convert-to-responses-api} - container: "nemo-rl" + input_dir: ${directories.step-2-apply-prompt-template} + output_dir: ${directories.step-3-convert-to-responses-api} + environments: ${environments} + container: "nemo-skills" # Pure Python JSONL transform; runs on CPU nodes without nvidia-container-cli dependencies: - apply_prompt_template - # -------------------------------------------------------------------------- - # Stage 3: Train/Validation Split (shared: SFT + GRPO) [CPU-only] - # -------------------------------------------------------------------------- - # Split data into training and validation sets. - # Uses stratified sampling to maintain question_type distribution. - # keep_all_fields: true preserves all fields (needed for Responses API data). - train_validation_split: - input_file: ${directories.step-2-convert-to-responses-api}/final_result.jsonl - output_dir: ${directories.step-3-train-validation-split} - val_ratio: 0.1 - stratify_by: question_type - random_seed: 42 - keep_all_fields: true - - dependencies: - - convert_to_responses_api - # -------------------------------------------------------------------------- # Stage 4: Data Preparation (ng_prepare_data) [CPU-only] # -------------------------------------------------------------------------- # Stamps each JSONL record with agent_ref so NeMo-Gym can route it to the - # correct environment server during training. - # - # The stage auto-generates an agent config overlay from the ``agents`` - # list below, writes it to output_dir/, and passes it to ng_prepare_data. + # correct environment server during training. Agent definitions are derived + # from the top-level ``environments`` dict. # - # To use your own data: override agents[].datasets in your model config. - # jsonl_fpath can be relative to gym_path or absolute (for Lustre paths - # or data_transformation output). - # - # Output: train.jsonl + validation.jsonl (with agent_ref routing fields) + # Reads final_result.jsonl from step-3-convert-to-responses-api and emits + # train.jsonl (agent_ref stamped). No pre-rollout val split -- the final + # train/val split happens after collect_rollouts/compute_rewards in the + # train_validation_split stage below, so every question in this file gets + # rollouts and a reward profile. prepare_data: output_dir: ${directories.step-4-prepare-data} + input_dir: ${directories.step-3-convert-to-responses-api} + input_filename: final_result.jsonl mode: "train_preparation" + environments: ${environments} + + # Deterministic post-shuffle of train.jsonl (after ng_prepare_data). + # SDG data is per-filing clustered, and collect_rollouts splits + # contiguously (num_chunks=8 chunks via head|tail, max_num_samples via + # head -n N), so the unshuffled order produces unbalanced chunk workloads + # and unrepresentative pilot samples. The old pre-rollout + # train_validation_split used to shuffle implicitly; this restores that + # property now that train_validation_split runs post-rollout (step-7). + # Seed matches train_validation_split's default for consistency. Set + # shuffle: false to disable. + shuffle: true + random_seed: 42 dependencies: - - train_validation_split + - convert_to_responses_api should_download: false # download missing datasets from HuggingFace? # Container / runtime container: "nemo-rl" + num_gpus: 1 # nemo-rl container needs GPU node for nvidia-container-cli gym_path: "/opt/NeMo-RL/3rdparty/Gym-workspace/Gym" - # Sets up a Gym-local venv so ng_prepare_data / ng_collect_rollouts are on PATH. - # Must use explicit ".venv" because the container's UV_PROJECT_ENVIRONMENT - # env var would redirect to /opt/nemo_rl_venv otherwise. - # Anchored as &gym_install -- reused by collect_rollouts and compute_rewards. + # Sets up a NeMo-Gym venv in /tmp so writes stay container-local (not on + # the bind-mounted /opt/NeMo-RL). SLURM_JOB_ID suffix guarantees + # uniqueness even if two jobs share a node. + # Anchored as &gym_install -- reused by prefetch_cache, collect_rollouts, and compute_rewards. installation_command: &gym_install >- cd /opt/NeMo-RL/3rdparty/Gym-workspace/Gym - && uv venv .venv --python 3.12 - && source .venv/bin/activate + && uv venv /tmp/gym-venv-\${SLURM_JOB_ID} --python 3.12 + && source /tmp/gym-venv-\${SLURM_JOB_ID}/bin/activate && uv sync --active --extra dev - # Base NeMo-Gym configs (environment + model adapter). - # Must match env.nemo_gym.config_paths in grpo_presets.yaml. - # The stage appends a runtime-generated overlay as the last entry. - # The finance judge overlay (last) overrides the default STEM judge prompt. - nemo_gym_config_paths: - - responses_api_models/vllm_model/configs/vllm_model_for_training.yaml - - resources_servers/equivalence_llm_judge/configs/equivalence_llm_judge.yaml - - /workspace/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml - - # Agent definitions → auto-generates {output_dir}/agent_config_overlay.yaml. - # Each agent maps to one NeMo-Gym environment. For multi-environment - # training, add more entries to this list and include the corresponding - # environment config in nemo_gym_config_paths above. - # - # Override agents[].datasets[].jsonl_fpath in model configs. - # Default points to train_validation_split output (Responses API format). - agents: - - name: "equivalence_llm_judge_simple_agent" - agent_type: "simple_agent" # NeMo-Gym agent type - entrypoint: "app.py" - resources_server: - type: resources_servers - name: equivalence_llm_judge - model_server: - type: responses_api_models - name: policy_model - datasets: - - name: train - type: train - license: "TBD" - jsonl_fpath: ${directories.step-3-train-validation-split}/train.jsonl - - name: validation - type: validation - license: "TBD" - jsonl_fpath: ${directories.step-3-train-validation-split}/val.jsonl + # -------------------------------------------------------------------------- + # Prefetch Cache (optional, CPU-only; no step-N -- writes to cache-finance-sec-search) + # -------------------------------------------------------------------------- + # Pre-warms environment-specific caches before rollout collection. + # Only environments with a ``prefetch`` block in their config are processed; + # others are silently skipped. Currently used by finance_sec_search to + # populate the SEC filing metadata cache from SEC.gov. + # + # To disable: comment out "prefetch_cache" in pipeline_stages above. + # To force re-fetch: set force: true in the environment's prefetch block. + prefetch_cache: + container: "nemo-rl" + num_gpus: 1 # nemo-rl container needs GPU node for nvidia-container-cli + gym_path: "/opt/NeMo-RL/3rdparty/Gym-workspace/Gym" + environments: ${environments} + installation_command: *gym_install + dependencies: + - prepare_data # -------------------------------------------------------------------------- # Stage 5: Rollout Collection (ng_collect_rollouts) # -------------------------------------------------------------------------- - # Runs the model against the NeMo-Gym environment to collect rollouts - # with rewards and judge verdicts. + # Two-phase GRPO design: this stage is Phase 1 (offline difficulty profiling). + # Phase 2 (GRPO training) generates fresh on-policy rollouts internally. + # + # Why two phases instead of standard single-phase GRPO? + # Agentic environments (e.g. finance_sec_search) are expensive per rollout + # (multi-step tool use: search → download → retrieve → reason → submit). + # Single-phase GRPO would spend 8 full agentic rollouts on every prompt, + # including easy (reward=1 always) and impossible (reward=0 always) samples + # that produce zero reward variance → zero GRPO gradient → wasted GPU hours. + # + # Phase 1 runs fewer seeds at the model's default sampling params (not the + # high-exploration temp=1.0 used during training) to get a clean difficulty + # signal. The filter sub-job then curates a "sweet spot" training set: + # - Removes trivially easy samples (no learning signal) + # - Removes impossible/unanswerable samples (often SDG data quality issues) + # - Keeps prompts with reward variance (model sometimes succeeds, sometimes + # fails) where GRPO can improve the policy + # + # This acts as a learned curriculum filter powered by the model itself, + # reducing GPU waste from O(all_prompts × 8) to O(all × few_seeds) + + # O(sweet_spot × 8), and doubles as a data cleaning step that catches + # wrong gold answers, unanswerable questions, and ambiguous samples. + # + # Related work supporting this design: + # [1] PODS: "Not All Rollouts are Useful" (Xu et al., 2025, arXiv:2504.13818) + # Decouples rollout generation from policy updates; trains on a + # max-variance subset → 1.7× faster GRPO convergence. + # [2] DAPO: "Dynamic Sampling" (Yu et al., 2025, arXiv:2503.14476, NeurIPS 2025) + # Filters zero-variance batches inside the training loop to avoid + # wasted compute on uninformative samples. + # [3] XRPO: "Targeted Exploration" (Bamba et al., 2025, arXiv:2510.06672) + # Adaptive rollout allocation prioritizing high-uncertainty prompts; + # up to 2.7× training convergence speedup. # # Slurm sub-jobs (in dependency order): # 1. Rollout (GPU) -- N chunks x M seeds = N*M jobs # 2. Merge (CPU) -- 1 per seed (merge chunks + enrich + analyze) # 3. Aggregate(CPU) -- 1 job (cross-seed pass@k -> difficulty.jsonl) - # 4. Filter (CPU) -- 1 job (curate train.jsonl + validation.jsonl) + # 4. Filter (CPU) -- 1 job (curate train.jsonl; drops zero-reward-std + # samples that carry no GRPO gradient) # # Resume: re-running skips (seed, chunk) pairs whose .done file exists. # Set rerun_done: true to force re-execution. collect_rollouts: output_dir: ${directories.step-5-collect-rollouts} - container: "nemo-rl" + prepare_data_dir: ${directories.step-4-prepare-data} + container: "nemo-rl" # rollout client + Gym (GPU jobs) + post_container: "nemo-skills" # merge/analyze/aggregate/filter (CPU jobs, no GPU deps) gym_path: "/opt/NeMo-RL/3rdparty/Gym-workspace/Gym" + environments: ${environments} dependencies: - prepare_data + - prefetch_cache installation_command: *gym_install # ---- Rollout jobs (GPU): num_chunks x num_random_seeds --------------- # Each job starts policy vLLM + (optional) judge vLLM + NeMo-Gym client. - # Total Slurm GPUs/job = policy_vllm.num_gpus + judge_vllm.num_gpus. + # Total Slurm GPUs/job = policy_vllm.num_gpus + env judge_vllm.num_gpus. + # Judge vLLM is configured per-environment (see environments section). # Merge + analyze + aggregate sub-jobs run automatically after rollouts # (CPU-only, no config needed). rollout: - input_data: ${directories.step-4-prepare-data}/train.jsonl - prepare_data_dir: ${directories.step-4-prepare-data} - environment_name: equivalence_llm_judge - nemo_gym_config_paths: - - responses_api_models/vllm_model/configs/vllm_model.yaml - - resources_servers/equivalence_llm_judge/configs/equivalence_llm_judge.yaml - - /workspace/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml - agent_name: "equivalence_llm_judge_simple_agent" - num_samples_in_parallel: 64 # Concurrent requests to ng_collect_rollouts max_num_samples: null # null = all samples; N = truncate to first N rows num_chunks: 1 num_random_seeds: 8 starting_seed: 0 rerun_done: false - - # Policy vLLM server. + dependent_jobs: 0 # Chain N+1 identical jobs per (seed, chunk) for timeout resume + # Per-request overrides merged into every ng_collect_rollouts call. + # These override (but don't replace) per-record responses_create_params in the data. + # Common keys: max_output_tokens, temperature, top_p. + # Per-environment overrides: set responses_create_params in the environment + # config (e.g. finance_sec_search.responses_create_params) to override + # these shared defaults for specific environments. + responses_create_params: {} + + # Policy vLLM server (shared across all environments). # Keys: num_gpus, server_nodes, base_url, model_path are orchestration-only. + # tensor_parallel_size is derived from num_gpus automatically (NON_VLLM_KEYS); + # do NOT set it here -- it will be silently ignored. # All other keys become --key-value CLI args to vllm serve. policy_vllm: model_path: null # REQUIRED -- set in model config @@ -287,23 +499,16 @@ stages: server_nodes: 1 # Nodes for this vLLM (>1 uses Ray) max_model_len: 32768 dtype: bfloat16 + enable_auto_tool_choice: true # Required for tool-calling envs (e.g. finance_sec_search) + tool_call_parser: hermes # Harmless for non-tool-calling envs # base_url: "http://:/v1" # external server (set num_gpus: 0) - # Judge vLLM server. - # Modes: local_vllm (model_path), external_vllm (base_url), - # openai (openai_base_url), policy_as_judge (default if num_gpus: 0). - judge_vllm: - num_gpus: 0 # 0 = no local judge - # See qwen3_4b.yaml for local vLLM judge example. - # ---- Filter job (CPU): curate training data -------------------------- - # Runs after aggregate; produces train.jsonl + validation.jsonl at - # the stage output_dir root. Remove this block to skip filtering. + # Runs after aggregate; produces train.jsonl at the stage output_dir + # root (one per env). Consumed by train_validation_split below. + # Remove this block to skip filtering. filter: - input_data: ${directories.step-4-prepare-data}/train.jsonl - validation_data: ${directories.step-4-prepare-data}/validation.jsonl - min_pass_rate: 0 - max_pass_rate: 1 + min_reward_std: 1e-6 # remove questions with zero reward variance (no GRPO gradient) # -------------------------------------------------------------------------- # Stage 6: Compute Rewards (re-judge rollouts) [OPTIONAL] @@ -323,115 +528,105 @@ stages: # 1. Re-judge (GPU) -- 1 per seed (judge vLLM + verify client) # 2. Analysis (CPU) -- 1 job (per-seed reward distribution) # 3. Aggregate(CPU) -- 1 job (cross-seed pass@k -> difficulty.jsonl) - # 4. Filter (CPU) -- 1 job (curate train.jsonl + validation.jsonl) + # 4. Filter (CPU) -- 1 job (curate train.jsonl; drops zero-reward-std samples) compute_rewards: output_dir: ${directories.step-6-compute-rewards} - container: "nemo-rl" + rollouts_dir: ${directories.step-5-collect-rollouts} + prepare_data_dir: ${directories.step-4-prepare-data} + container: "nemo-rl" # verify client + Gym (GPU jobs) + post_container: "nemo-skills" # analysis/aggregate/filter (CPU jobs, no GPU deps) gym_path: "/opt/NeMo-RL/3rdparty/Gym-workspace/Gym" + environments: ${environments} dependencies: - collect_rollouts installation_command: *gym_install # ---- Re-judge jobs (GPU): 1 per seed --------------------------------- # Each job starts a judge vLLM server + verify client. - # Slurm GPUs/job = judge_vllm.num_gpus. + # Slurm GPUs/job = env judge_vllm.num_gpus. + # Judge vLLM is configured per-environment (see environments section). # Analysis + aggregate sub-jobs run automatically after re-judging # (CPU-only, no config needed). rejudge: - input_dir: ${directories.step-5-collect-rollouts}/rollout - prepare_data_dir: ${directories.step-4-prepare-data} - environment_name: equivalence_llm_judge - # vllm_model.yaml is required even though the rejudge has no policy model: - # equivalence_llm_judge.yaml references policy_model in the simple_agent - # block, and NeMo-Gym validates all server refs at startup. verify.py - # passes dummy policy_model overrides to satisfy ${policy_base_url}. - nemo_gym_config_paths: - - responses_api_models/vllm_model/configs/vllm_model.yaml - - resources_servers/equivalence_llm_judge/configs/equivalence_llm_judge.yaml - - /workspace/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml num_samples_in_parallel: 64 rerun_done: false - # Judge vLLM server (REQUIRED -- no policy-as-judge in this stage). - # See qwen3_4b.yaml for concrete examples. - judge_vllm: - num_gpus: 0 # 0 = OpenAI API judge (no local GPU needed) - # --- Local vLLM judge --- - # num_gpus: 4 - # server_nodes: 1 # Nodes for this vLLM (>1 uses Ray) - # model_path: /hf_models/Qwen/Qwen3-32B - # max_model_len: 32768 - # dtype: bfloat16 - # - # --- External vLLM judge (pre-launched server) --- - # base_url: "http://:/v1" - # model_path: /hf_models/Qwen/Qwen3-32B - # - # --- OpenAI API judge --- - # num_gpus: 0 - # openai_base_url: "https://api.openai.com/v1" - # openai_model: "gpt-4o" - # ---- Filter job (CPU): curate training data -------------------------- - # Runs after aggregate; produces train.jsonl + validation.jsonl at - # the stage output_dir root. Remove this block to skip filtering. + # Runs after aggregate; produces train.jsonl at the stage output_dir + # root (one per env). To feed this into training, point + # train_validation_split.input_dir at ${directories.step-6-compute-rewards}. + # Remove this block to skip filtering. filter: - input_data: ${directories.step-4-prepare-data}/train.jsonl - validation_data: ${directories.step-4-prepare-data}/validation.jsonl - min_pass_rate: 0 - max_pass_rate: 1 + min_reward_std: 1e-6 # remove questions with zero reward variance (no GRPO gradient) # -------------------------------------------------------------------------- - # Stage 7: GRPO Training (Reinforcement Learning) + # Stage 7: Train/Validation Split (post-rollout, on reward-filtered data) + # -------------------------------------------------------------------------- + # Runs LAST before training so it always operates on the most recent + # filtered output: + # - compute_rewards disabled (common case): reads collect_rollouts filter + # sub-job output (${directories.step-5-collect-rollouts}//train.jsonl). + # - compute_rewards enabled: model config overrides input_dir + dependencies + # to point at ${directories.step-6-compute-rewards}. + # + # val_ratio is tight (0.01 = 99/1) because the filtered set is already small + # and every row has a reward profile -- we keep almost all of it for training. + train_validation_split: + input_dir: ${directories.step-5-collect-rollouts} + output_dir: ${directories.step-7-train-validation-split} + environments: ${environments} + val_ratio: 0.01 + stratify_by: question_type + random_seed: 42 + keep_all_fields: true + input_filename: train.jsonl + dependencies: + - collect_rollouts + + # -------------------------------------------------------------------------- + # Stage 8: GRPO Training (Reinforcement Learning) # -------------------------------------------------------------------------- # Runs GRPO training with NeMo-RL + NeMo-Gym. # The NeMo-Gym environment provides reward signals for RL training. # - # Data: Consumes filtered JSONL from the collect_rollouts filter sub-job. - # The filtered data contains only "sweet spot" questions with good RL signal. - # If compute_rewards is enabled, override paths to point to its output. + # Data: Consumes the post-rollout train/val split. Always reads from + # step-7-train-validation-split regardless of whether compute_rewards ran + # -- the dispatch is entirely in train_validation_split.input_dir. # - # Environment: Configurable via overrides.env.nemo_gym.config_paths + # Environment: Derived from the top-level `environments` dict. training: - # Data paths -- wired to collect_rollouts output (filtered train + copied val). - # If compute_rewards is enabled, override in model config: - # training_data: ${directories.step-6-compute-rewards}/train.jsonl - # validation_data: ${directories.step-6-compute-rewards}/validation.jsonl - # dependencies: [compute_rewards] - training_data: ${directories.step-5-collect-rollouts}/train.jsonl - validation_data: ${directories.step-5-collect-rollouts}/validation.jsonl - output_dir: ${directories.step-7-training} - - # Slurm dependency: wait for collect_rollouts (including filter sub-job). + # Data source directory -- per-env train.jsonl + val.jsonl (from the + # dataset_splitter) are read from {data_source_dir}/{env_name}/. + data_source_dir: ${directories.step-7-train-validation-split} + output_dir: ${directories.step-8-training} + environments: ${environments} + # dataset_splitter writes val.jsonl (not training.py's default + # "validation.jsonl"), so align val_filename here once and model + # configs inherit it automatically. + val_filename: val.jsonl + + # Slurm dependency: wait for the final train/val split. dependencies: - - collect_rollouts + - train_validation_split # MODEL-SPECIFIC - Override in model configs: - # model_name, hf_checkpoint_path, num_nodes, preset, overrides - - # Judge vLLM server for GRPO training. - # Modes: local_vllm (model_path), external_vllm (base_url), - # openai (openai_base_url), policy_as_judge (default if absent). - # See qwen3_4b.yaml for local vLLM judge example. - # judge_vllm: - # num_gpus: 0 + # model_name, hf_checkpoint_path, total_gpus, preset, overrides + # Judge vLLM for training is configured per-environment (see environments section). # Cluster configuration (shared defaults) - num_gpus: 8 - dependent_jobs: 0 + # total_gpus: total GPUs for the training job (auto-split across nodes + # using gpus_per_node from the cluster config) + total_gpus: 8 + dependent_jobs: 0 # Chain N+1 Slurm jobs for checkpoint resumption backend: fsdp # fsdp | megatron (override in model configs) # Experiment tracking wandb_project: finance-grpo wandb_mode: disabled # online | offline | disabled - # Entry point swap: nemo-skills hardcodes start_grpo.py (MathEnvironment only). - # We copy the NeMo-Gym entry point over it so the correct script runs. - # Also installs NeMo-Gym dependencies via uv sync. + # Installs NeMo-Gym dependencies in the NeMo-RL container. installation_command: >- - cp /opt/NeMo-RL/examples/nemo_gym/run_grpo_nemo_gym.py - /nemo_run/code/nemo_skills/training/nemo_rl/start_grpo.py - && cd /opt/NeMo-RL && uv sync --extra nemo_gym + cd /opt/NeMo-RL && uv sync --extra nemo_gym # Runtime environment customization # stage_kwargs: @@ -442,7 +637,7 @@ stages: # partition: batch # -------------------------------------------------------------------------- - # Stage 8: Evaluate Checkpoints on Finance Benchmarks + # Stage 9: Evaluate Checkpoints on Finance Benchmarks # -------------------------------------------------------------------------- # Evaluates GRPO training checkpoints at specified steps using nemo-skills. # Shared settings (benchmarks, judges, datasets) are loaded from @@ -452,6 +647,6 @@ stages: # eval_steps, checkpoint_path, baseline_model, server_type, gpus, # eval_output_dir, inference_args, server_args eval: - eval_output_dir: ${directories.step-8-eval} + eval_output_dir: ${directories.step-9-eval} dependencies: - training diff --git a/nvflow/recipes/finance/workflows/grpo/grpo_presets.yaml b/nvflow/recipes/finance/workflows/grpo/grpo_presets.yaml index 3393c70..1c92945 100644 --- a/nvflow/recipes/finance/workflows/grpo/grpo_presets.yaml +++ b/nvflow/recipes/finance/workflows/grpo/grpo_presets.yaml @@ -7,20 +7,19 @@ # This is a FULL preset (like SFT) -- every parameter we pass is explicit. # Model configs (e.g., qwen3_4b.yaml) override model-specific values. # -# Aligned with NeMo-Gym example config: -# RL/examples/nemo_gym/grpo_dapo17k_bytedtsinghua_qwen3_4binstruct_nf.yaml -# at NeMo-RL commit e95efb91 (the version in our nemo-skills container). +# Aligned with NeMo-Gym example configs in RL/examples/nemo_gym/ +# targeting NeMo-RL main branch (mounted as overlay at /opt/NeMo-RL). # # Deviations from the NeMo-Gym example are marked with: # [FINANCE] -- intentional change for our open-QA / finance setup # [FIX] -- upstream bug workaround -# [MANAGED] -- key is set by nemo-skills at runtime; value here is -# included in the config YAML but overridden by nemo-skills -# via ++key=value CLI args (last-write-wins) +# [MANAGED] -- key is set at runtime via ++key=value CLI args +# (last-write-wins) by training.py _build_train_cmd() # # The full preset is written as a YAML file and passed to -# run_grpo_nemo_gym.py via --config. nemo-skills' runtime overrides -# (model_name, cluster, checkpoint_dir, etc.) are applied on top. +# run_grpo_nemo_gym.py via --config. Runtime overrides +# (model_name, cluster, checkpoint_dir, data paths, etc.) are applied +# on top via ++key=value CLI args by training.py. # ============================================================================ presets: @@ -38,6 +37,7 @@ presets: use_leave_one_out_baseline: true val_period: 10 val_at_start: true + val_at_end: true overlong_filtering: false max_val_samples: null # Inferred from validation dataset size val_batch_size: null @@ -46,6 +46,7 @@ presets: dynamic_sampling_max_gen_batches: 10 batch_multiplier: 1 skip_reference_policy_logprobs_calculation: true + calculate_advantages_on_gpu: true reward_shaping: enabled: false @@ -53,6 +54,8 @@ presets: overlong_buffer_penalty: 1 max_response_length: 32768 # Should match policy.max_total_sequence_length + seq_logprob_error_threshold: null + reward_scaling: enabled: false source_min: 0.0 @@ -83,25 +86,21 @@ presets: checkpointing: enabled: true # [MANAGED] checkpoint_dir and checkpoint_must_save_by are set by - # nemo-skills grpo_nemo_rl() at runtime (output path and partition timeout). - # Values here are overridden by nemo-skills at runtime via CLI args. + # training.py _build_train_cmd() at runtime via CLI args. checkpoint_dir: "results/grpo" checkpoint_must_save_by: null metric_name: "val:accuracy" higher_is_better: true - keep_top_k: 3 + keep_top_k: 20 save_period: 1 - # [FIX] model_save_format must be null for DTensor v1 (_v2=false). - # Set to "safetensors" only when using DTensor v2 or Megatron backend. - model_save_format: null save_consolidated: false + save_optimizer: true # ======================================================================== # Policy Configuration (Model + Training + Generation) # ======================================================================== policy: - # [MANAGED] model_name is set by nemo-skills from hf_checkpoint_path. - # Value here is overridden by nemo-skills at runtime via CLI args. + # [MANAGED] model_name is set by training.py from hf_checkpoint_path. model_name: null # e.g., "Qwen/Qwen3-4B" tokenizer: name: null # Set in model config (e.g. qwen3_4b.yaml) @@ -122,8 +121,8 @@ presets: # DTensor (FSDP) configuration - default backend for GRPO dtensor_cfg: - _v2: false - # [MANAGED] enabled is set by nemo-skills based on backend parameter. + _v2: true + # [MANAGED] enabled is set by training.py based on backend parameter. enabled: true cpu_offload: false sequence_parallel: false @@ -132,34 +131,69 @@ presets: context_parallel_size: 1 custom_parallel_plan: null clear_cache_every_n_steps: null + automodel_kwargs: {} + # ---------------------------------------------------------------------- # Megatron configuration (disabled by default for GRPO) - # Enable via backend: megatron in model config + # Enable via backend: megatron in model config. + # Layout mirrors sft_presets.yaml and NeMo-RL's grpo_math_1B.yaml defaults. + # ---------------------------------------------------------------------- megatron_cfg: - # [MANAGED] enabled is set by nemo-skills based on backend parameter. + # [MANAGED] enabled is set by training.py based on backend parameter. enabled: false - empty_unused_memory_level: 0 - activation_checkpointing: true - converter_type: "Qwen2ForCausalLM" + env_vars: null + empty_unused_memory_level: 1 + + # --- Parallelism (dense defaults; override per-model for MoE) -------- + activation_checkpointing: false tensor_model_parallel_size: 1 - expert_tensor_parallel_size: 1 - expert_model_parallel_size: 1 pipeline_model_parallel_size: 1 - num_layers_in_first_pipeline_stage: null - num_layers_in_last_pipeline_stage: null context_parallel_size: 1 - pipeline_dtype: "bfloat16" + expert_model_parallel_size: 1 + expert_tensor_parallel_size: 1 sequence_parallel: false + pipeline_dtype: "bfloat16" + num_layers_in_first_pipeline_stage: null + num_layers_in_last_pipeline_stage: null + + # --- Compute / precision ------------------------------------------ + apply_rope_fusion: true + bias_activation_fusion: true + defer_fp32_logits: true + layernorm_epsilon: 1e-6 + + # --- MoE parameters (safe defaults for both MoE and dense models) - + # For dense models these are no-ops; for MoE override per-model. freeze_moe_router: true moe_router_dtype: "fp64" moe_router_load_balancing_type: "none" + moe_aux_loss_coeff: 0.0 moe_router_bias_update_rate: 0.0 - apply_rope_fusion: true - defer_fp32_logits: true - moe_permute_fusion: false - bias_activation_fusion: true - moe_per_layer_logging: false + moe_permute_fusion: true + moe_enable_deepep: false + moe_token_dispatcher_type: "allgather" + moe_shared_expert_overlap: false + gradient_accumulation_fusion: false # Required by v0.6.0 community_import.py + moe_per_layer_logging: true + # --- MTP (Multi-Token Prediction) --------------------------------- + mtp_loss_scaling_factor: 0.0 + mtp_use_repeated_layer: false + mtp_num_layers: 0 + mtp_detach_heads: false + + # --- FP8 configuration -------------------------------------------- + fp8_cfg: + enabled: false + fp8: "e4m3" + fp8_recipe: "blockwise" + fp8_param: false + + first_last_layers_bf16: false + num_layers_at_start_in_bf16: 1 + num_layers_at_end_in_bf16: 1 + + # --- Optimizer ---------------------------------------------------- optimizer: optimizer: "adam" lr: 5.0e-6 @@ -174,10 +208,11 @@ presets: sgd_momentum: 0.9 use_distributed_optimizer: true use_precision_aware_optimizer: true + clip_grad: 1.0 optimizer_cpu_offload: false optimizer_offload_fraction: 0.0 - clip_grad: 1.0 + # --- Scheduler ---------------------------------------------------- scheduler: start_weight_decay: 0.01 end_weight_decay: 0.01 @@ -187,6 +222,7 @@ presets: lr_warmup_iters: 13 lr_warmup_init: 5.0e-7 + # --- Distributed Data Parallel ------------------------------------ distributed_data_parallel_config: grad_reduce_in_fp32: false overlap_grad_reduce: true @@ -194,8 +230,6 @@ presets: use_custom_fsdp: false data_parallel_sharding_strategy: "optim_grads_params" - env_vars: null - # Dynamic batching (disabled by default) dynamic_batching: enabled: false @@ -250,7 +284,7 @@ presets: pipeline_parallel_size: 1 enable_expert_parallel: false expert_parallel_size: 1 - gpu_memory_utilization: 0.8 + gpu_memory_utilization: 0.7 max_model_len: 32768 enforce_eager: false use_deep_gemm: false @@ -273,30 +307,33 @@ presets: # ======================================================================== # Environment Configuration (NeMo-Gym) # ======================================================================== - # [FINANCE] NeMo-Gym example uses library_judge_math for math problems. - # We use equivalence_llm_judge for open-QA semantic equivalence. - # To change: override env.nemo_gym in your model config. + # config_paths is built dynamically by training.py _resolve_nemo_rl_config() + # from the top-level environments dict in base.yaml. # # Judge model routing: - # The training stage dynamically injects judge_model_server.name and - # the judge_model adapter config based on training.judge_vllm in the - # workflow YAML. No hardcoded judge config is needed here. + # The training stage dynamically injects judge_model config based on + # training.judge_vllm in the workflow YAML. # See: nvflow/recipes/finance/stages/rl/training.py (_inject_judge_config) env: should_use_nemo_gym: true should_log_nemo_gym_responses: true - nemo_gym: - config_paths: - - responses_api_models/vllm_model/configs/vllm_model_for_training.yaml - - resources_servers/equivalence_llm_judge/configs/equivalence_llm_judge.yaml - - /workspace/nvflow/recipes/finance/prompts/finance_openqa_judge_overlay.yaml + nemo_gym: {} # ======================================================================== - # Data Configuration + # Data Configuration (NeMo-RL main format) # ======================================================================== + # train.data_path and validation.data_path are injected at runtime + # via ++data.train.data_path=... by training.py _build_train_cmd(). data: + max_input_seq_length: null shuffle: true num_workers: 0 + use_multiple_dataloader: false + train: {} + validation: {} + default: + dataset_name: NemoGymDataset + processor: "nemo_gym_data_processor" # ======================================================================== # Logger Configuration @@ -304,12 +341,12 @@ presets: # [FINANCE] NeMo-Gym example enables W&B with project "grpo-dev". # We disable W&B by default and use finance-specific project names. logger: - # [MANAGED] log_dir is set by nemo-skills to the output directory. + # [MANAGED] log_dir is set by training.py at runtime. log_dir: "logs" num_val_samples_to_print: 5 wandb_enabled: false swanlab_enabled: false - tensorboard_enabled: false + tensorboard_enabled: true mlflow_enabled: false monitor_gpus: true wandb: @@ -330,8 +367,7 @@ presets: # Cluster Configuration (overridden by nemo-skills) # ======================================================================== # [MANAGED] cluster.gpus_per_node and cluster.num_nodes are set by - # nemo-skills from num_gpus and num_nodes parameters. - # Values here are overridden by nemo-skills at runtime via CLI args. + # training.py _build_train_cmd() at runtime via CLI args. cluster: gpus_per_node: 8 num_nodes: 1 diff --git a/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_demo.yaml b/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_demo.yaml new file mode 100644 index 0000000..1031017 --- /dev/null +++ b/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_demo.yaml @@ -0,0 +1,7 @@ +# Scope: demo (small dataset for development & smoke tests). +# Pairs with data_root: /workspace/outputs/finance/demo in the model config. +finance_sec_search_resources_server: + resources_servers: + finance_sec_search: + cache_dir: /workspace/outputs/finance/demo/workflow-5-grpo/cache/finance_sec_search + sec_dump_path: /workspace/outputs/finance/demo/workflow-2-download-sec/step-0-download/data diff --git a/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_env.yaml b/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_env.yaml new file mode 100644 index 0000000..7f3a1d1 --- /dev/null +++ b/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_env.yaml @@ -0,0 +1,50 @@ +# Resolve OmegaConf interpolations in finance_sec_search.yaml's search_judge_model block. +# +# The upstream config references ${search_judge_model_*} variables (normally +# provided by a local env.yaml). Our GRPO pipeline overrides +# judge_model_server.name to point to the dedicated judge_model server, +# so search_judge_model is never used at runtime — these are just +# placeholders to satisfy OmegaConf config resolution. +# +# Judge configuration lives in judge_vllm (qwen3_4b.yaml / base.yaml). +search_judge_model_base_url: "http://unused:0/v1" +search_judge_model_api_key: "EMPTY" +search_judge_model_name: "unused" + +# Per-rollout wall-clock timeout. Tool calls after this budget return an error +# asking the model to submit immediately. Prevents stragglers from blocking the batch. +# +# Judge and retrieval prompts are loaded from files in the prompts/ folder +# so NVFlow users can tune them without editing the Gym repo. +# Inline values take priority over file paths in the Gym config. +finance_sec_search_resources_server: + resources_servers: + finance_sec_search: + max_rollout_time_seconds: 1800 # 30-min wall-clock budget per rollout + reward_mode: scaled # [[0]]→0.0, [[1]]→0.5, [[2]]→1.0 (partial credit for GRPO) + judge_prompt_template_fpath: /workspace/nvflow/recipes/finance/prompts/finance_sec_search_judge.yaml + retrieval_system_prompt_fpath: /workspace/nvflow/recipes/finance/prompts/finance_sec_search_retrieval.yaml + # Retrieval sub-calls go to the policy vLLM worker, which asserts + # on-policy sampling params. Must match generation.temperature / top_p + # from the GRPO preset (grpo_presets.yaml). + retrieval_responses_create_params: + input: [] + temperature: 1.0 + top_p: 1.0 + +# Cap agent loop iterations to bound context length and compute per rollout. +# Typical workflow: search -> download -> retrieve (x few) -> submit fits in ~6-8 steps; +# harder queries may need 10-15 retrieval refinements. +# Raised from 15 to 50 after pilot analysis: at max_steps=15, 78% of rollouts +# truncated mid-tool (median needed 22 steps, max=45). 50 covers p99 while +# still bounding pathological loops. Context limit (131K) and time budget +# (1800s) provide independent safety bounds. +finance_agent: + responses_api_agents: + finance_agent: + max_steps: 50 + +# Resource server path overrides live in scope-specific overlays: +# finance_sec_search_demo.yaml — for demo runs +# finance_sec_search_sap500.yaml — for S&P 500 production runs +# Select the right one via config_paths in the model config (qwen3_4b.yaml). diff --git a/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_sap500.yaml b/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_sap500.yaml new file mode 100644 index 0000000..082feaf --- /dev/null +++ b/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_sap500.yaml @@ -0,0 +1,7 @@ +# Scope: S&P 500 (full production training). +# Pairs with data_root: /workspace/outputs/finance/sap-500 in the model config. +finance_sec_search_resources_server: + resources_servers: + finance_sec_search: + cache_dir: /workspace/outputs/finance/sap-500/workflow-5-grpo/cache/finance_sec_search + sec_dump_path: /workspace/outputs/finance/sap-500/workflow-2-download-sec/step-0-download/data diff --git a/nvflow/recipes/finance/workflows/grpo/qwen3_30b_a3b.yaml b/nvflow/recipes/finance/workflows/grpo/qwen3_30b_a3b.yaml new file mode 100644 index 0000000..8ee69a6 --- /dev/null +++ b/nvflow/recipes/finance/workflows/grpo/qwen3_30b_a3b.yaml @@ -0,0 +1,348 @@ +# ============================================================================ +# Qwen3-30B-A3B GRPO Training Configuration (MoE, DG SDG) +# ============================================================================ +# GRPO RL training for Qwen3-30B-A3B (Mixture of Experts) using +# Document-Grounded SDG hard_rl_data from SAP-500. +# +# Inherits shared pipeline logic from base.yaml and provides all +# Qwen3-30B-A3B specific settings (MoE parallelism, context extension, etc.) +# +# Key differences from Qwen3-4B: +# - MoE architecture: 128 experts, top-k=8 → requires expert_model_parallel_size +# - DG SDG data: raw_train_data points to workflow-3-document-grounded-sdg +# (symlink final_result.jsonl → hard_rl_data.jsonl) +# - answer_prefix: null for all envs (DG SDG has no "Answer:" prefix; +# full answer text used as GOLD for richer partial-reward signal +# with thinking models) +# - Larger Megatron parallelism: TP=4, CP=4, EP=8 on 64 GPUs (DP=4) +# +# Usage: +# uv run nflow run-all --config nvflow/recipes/finance/workflows/grpo/qwen3_30b_a3b.yaml +# +# Data: +# DG SDG hard_rl_data.jsonl (414K records, difficulty_score=0). +# Symlinked as final_result.jsonl so data_transformation reads it unchanged. +# The `answer` field (not `generation`) is consumed via fallback in +# dataset_transformer.py. +# ============================================================================ + +# Inherit base GRPO workflow +_base_: base.yaml + +# Data scope: SAP-500 DG SDG data +data_root: /workspace/outputs/finance/sap-500 + +# Model identity (define once, reference everywhere via ${hf_model_path}) +hf_model_path: /hf_models/Qwen/Qwen3-30B-A3B + +# Model-specific output directory (stages 5-9: rollouts, compute_rewards, +# train_validation_split, training, eval write here) +model_output_dir: ${base_output_dir}/qwen3_30b_a3b + +# Same pipeline as base.yaml -- listed here because YAML lists fully replace +# (not merge) when inherited via _base_, so we must re-declare. +pipeline_stages: + - validate_questions + - data_transformation + - apply_prompt_template + - convert_to_responses_api + - prepare_data + - prefetch_cache + - collect_rollouts + - train_validation_split + - training + - eval + +# ============================================================================ +# Shared Anchors (define once, reuse across stages) +# ============================================================================ +# GPT-OSS-120B as dedicated judge (MoE, TP=2 to avoid GPU idle reaper). +_judge_vllm: &judge_vllm + model_path: /hf_models/openai/gpt-oss-120b + num_gpus: 2 + server_nodes: 1 + max_model_len: 32768 + async_scheduling: true + uses_reasoning_parser: true + +# Per-environment sequence length for equiv/mcqa (finance_sec_search uses 131K model default) +_env_seq_len_32k: 32768 + +_training_policy_32k: &training_policy_32k + max_total_sequence_length: ${_env_seq_len_32k} + hf_config_overrides: null # No YaRN needed at 32K (native context is 40960) + megatron_cfg: + context_parallel_size: 2 # CP=4 is for 131K; CP=2 sufficient for 32K + generation: + vllm_cfg: + max_model_len: ${_env_seq_len_32k} + sequence_packing: + train_mb_tokens: ${_env_seq_len_32k} + logprob_mb_tokens: ${_env_seq_len_32k} + +# DG SDG data path (shared across equivalence_llm_judge and finance_sec_search) +_dg_sdg_data: &dg_sdg_data /workspace/outputs/finance/sap-500/workflow-3-document-grounded-sdg + +# ============================================================================ +# Environment Overrides +# ============================================================================ +# answer_prefix: null for all environments. +# DG SDG `answer` field has no "Answer:" prefix. The full answer text +# (1-2K chars) serves as the GOLD reference, enabling partial rewards +# from the 3-point finance judges. Revisit if training a non-thinking model. +environments: + equivalence_llm_judge: + raw_train_data: *dg_sdg_data + answer_prefix: null + judge_vllm: *judge_vllm + training_policy: *training_policy_32k + mcqa: + training_repeat: 2 + training_policy: *training_policy_32k + finance_sec_search: + # IMPORTANT: raw_train_data points at the validate_questions output, + # NOT the raw SDG. The validate_questions stage reads from *dg_sdg_data + # (via stages.validate_questions.source_data below) and writes the + # filtered dataset to ${directories.step-0-validate-questions}/finance_sec_search/. + # data_transformation then reads via this path as it always has. + # If validate_questions is removed from pipeline_stages, revert this to + # *dg_sdg_data. + raw_train_data: ${directories.step-0-validate-questions}/finance_sec_search + config_paths: + - resources_servers/finance_sec_search/configs/finance_sec_search.yaml + - /workspace/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_env.yaml + - /workspace/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_sap500.yaml + policy_vllm: + num_gpus: 4 + max_model_len: 131072 + hf_config_overrides: + rope_scaling: + rope_type: yarn + factor: 3.2 + original_max_position_embeddings: 40960 + judge_vllm: *judge_vllm + +# ============================================================================ +# Model-Specific Stage Overrides +# ============================================================================ + +stages: + + # -------------------------------------------------------------------------- + # Validate Questions: GPT-OSS-120B pre-filter + # -------------------------------------------------------------------------- + # Always invoke with `-e finance_sec_search` — the SEC-filings rubric doesn't + # apply to equivalence_llm_judge / mcqa question distributions. + # + # stage_kwargs goes straight to nemo_skills.pipeline.cli.generate(), so keys + # must match its signature (model / server_gpus / server_args / ...). Do NOT + # spread the _judge_vllm anchor here — its keys (model_path / num_gpus / + # max_model_len / ...) are rejected by generate(). + validate_questions: + source_data: *dg_sdg_data + stage_kwargs: + model: /hf_models/openai/gpt-oss-120b + server_type: vllm + server_gpus: 4 + server_nodes: 1 + # Single chunk: 10K dryrun extrapolates to ~42-90 min for the full + # ~379K prefiltered records on one 4-GPU server, well under the + # 4h Slurm timeout. Runs 1 job instead of 8 = 1/8 the cluster + # GPU-hour footprint with a moderate wall-clock tradeoff. + # If resilience against a single-job timeout becomes a concern, + # bump num_chunks to 2-4 as a middle ground. + num_chunks: 1 + # Concurrency lever is --max-num-seqs (vLLM's 1024 default was the + # binding cap in dryrun measurements); --max-num-batched-tokens + # is slack for prefill bursts. Both are scheduler-only, no + # quality impact. + server_args: >- + --max-model-len 65536 + --async-scheduling + --reasoning-parser openai_gptoss + --max-num-batched-tokens 32768 + --max-num-seqs 4096 + # No stage-level inline_args override here -- we inherit base.yaml's + # default (reasoning_effort=high, tokens_to_generate=8192, + # max_concurrent_requests=2048) which has no max_samples cap, so the + # LLM processes every prefiltered record. + + # -------------------------------------------------------------------------- + # Rollout Collection: Qwen3-30B-A3B configuration + # -------------------------------------------------------------------------- + collect_rollouts: + rollout: + num_random_seeds: 1 + num_chunks: 8 + dependent_jobs: 2 + num_samples_in_parallel: 512 + starting_seed: 0 + policy_vllm: + model_path: ${hf_model_path} + num_gpus: 4 # TP = 4 (one node, 4 GPUs) + server_nodes: 1 + # Max concurrent sequences in vLLM. Reduced from 512 → 448 to + # prevent KV cache saturation on heavy data (seed 0 hit 100% KV + # sustained at 512, dropping prefix cache to 11% and throughput + # by ~30%). At 448 the steady-state KV stays ≤87% with no + # throughput penalty (GPU is not the bottleneck). + max_num_seqs: 448 + # To scale with data parallelism (multi-node vLLM): + # data_parallel_size: 2 # DP replicas; increase with server_nodes + num_samples_in_parallel + # data_parallel_backend: ray # multi-node DP via Ray + # Note: for agentic workloads (multi-turn tool use), DP>1 may not + # improve throughput — the bottleneck is client-side orchestration, + # not GPU inference. + reasoning_parser: qwen3 + + # -------------------------------------------------------------------------- + # Train/Validation Split: curriculum ordering for GRPO + # -------------------------------------------------------------------------- + # Sort train.jsonl by offline difficulty (easy-to-hard) instead of shuffling. + # Early training steps see high-reward prompts → strong GRPO gradients. + train_validation_split: + sort_by: "difficulty_profile.avg_reward" + sort_order: "desc" + + # -------------------------------------------------------------------------- + # Training: Qwen3-30B-A3B GRPO configuration (Megatron, MoE) + # -------------------------------------------------------------------------- + # data_source_dir and dependencies are inherited from base.yaml + # (step-7-train-validation-split / [train_validation_split]) since we use + # the default post-rollout split. Only model-specific fields below. + training: + model_name: Qwen/Qwen3-30B-A3B + hf_checkpoint_path: ${hf_model_path} + + # Megatron backend required for MoE expert parallelism + backend: megatron + + # Production: 64 GPUs = 8 nodes x 8 GPUs (DP=4) + # Model parallel = TP(4) x CP(4) x PP(1) = 16 → DP = 64/16 = 4 + total_gpus: 64 + dependent_jobs: 2 + + wandb_mode: online + + preset: "grpo-base" + + overrides: + data: + shuffle: false + + grpo: + num_prompts_per_step: 128 + num_generations_per_prompt: 8 + max_num_epochs: 1 + val_period: 4 + use_dynamic_sampling: true + batch_multiplier: 2 + dynamic_sampling_max_gen_batches: 10 + + # GSPO loss formulation for MoE stability + # (ref: RL/examples/nemo_gym/grpo_qwen3_30ba3b_instruct.yaml) + # sequence_level_importance_ratios: one ratio per sequence (not per token), + # smoothing MoE routing-induced logprob variance across tokens. + loss_fn: + sequence_level_importance_ratios: true + token_level_loss: false + ratio_clip_min: 3.0e-4 + ratio_clip_max: 3.0e-4 + + checkpointing: + save_period: 2 + checkpoint_must_save_by: "00:03:30:00" + + policy: + tokenizer: + name: ${stages.training.hf_checkpoint_path} + # Qwen3-30B-A3B (MoE) needs YaRN to extend context beyond native 40960 + hf_config_overrides: + rope_scaling: + rope_type: yarn + rope_theta: 1000000 + factor: 3.2 + original_max_position_embeddings: 40960 + truncate: true + beta_fast: 32 + beta_slow: 1 + mscale: 1 + mscale_all_dim: 0 + # 131K default context (required by finance_sec_search and combined training). + # Single-env equiv/mcqa override down to 32K via training_policy. + max_total_sequence_length: 131072 + generation: + vllm_cfg: + tensor_parallel_size: 4 + max_model_len: 131072 + http_server_serving_chat_kwargs: + reasoning_parser: qwen3 + # Batch size = num_prompts_per_step * num_generations_per_prompt = 128 * 8 = 1024 + train_global_batch_size: 1024 + + # Megatron parallelism for MoE: + # TP=4, CP=4, PP=1, EP=8 + # - TP=4: model sharding across 4 GPUs (matches NeMo-RL 40K reference) + # - CP=4: 131K / 4 = ~32K tokens/rank (activation ckpt handles memory) + # - EP=8: 128 experts / 8 = 16 experts per EP rank + # - DP = 64 / (TP*CP*PP) = 64/16 = 4 (doubled from CP=8 for throughput) + megatron_cfg: + tensor_model_parallel_size: 4 + pipeline_model_parallel_size: 1 + context_parallel_size: 4 + expert_model_parallel_size: 8 + expert_tensor_parallel_size: 1 + sequence_parallel: true + activation_checkpointing: true + empty_unused_memory_level: 1 + # moe_token_dispatcher_type: uses preset default (allgather). + # Consider alltoall for better comm efficiency at EP=8 if throughput-bound. + env_vars: + PYTORCH_ALLOC_CONF: "expandable_segments:False" + # LR for GSPO on 30B MoE (ref: grpo_qwen3_30ba3b_instruct.yaml) + # GSPO's tight clipping (3e-4) acts as implicit regularization, + # allowing higher LR than GRPO. Constant schedule (min_lr = lr). + optimizer: + lr: 2.0e-6 + min_lr: 2.0e-6 + scheduler: + lr_warmup_iters: 0 + lr_warmup_init: 2.0e-6 + + # Sequence packing (required for CP > 1) + sequence_packing: + enabled: true + train_mb_tokens: 131072 + logprob_mb_tokens: 131072 + + logprob_chunk_size: 2048 + + # Logger + logger: + wandb: + name: "gspo-qwen3-30b-a3b" + + # -------------------------------------------------------------------------- + # Stage 9: Evaluate Checkpoints - Qwen3-30B-A3B GRPO + # -------------------------------------------------------------------------- + eval: + eval_steps: [20] + checkpoint_path: ${directories.step-8-training} + base_output_dir: ${model_output_dir} + format: megatron + base_model: ${hf_model_path} + server_type: vllm + gpus: 4 + benchmarks: + secque: + seeds: 5 + financebench: + seeds: 5 + inference_args: >- + ++prompt_config=/workspace/nvflow/recipes/finance/prompts/secque_template.yaml + ++inference.temperature=0.6 + ++inference.top_p=0.95 + ++inference.top_k=20 + ++inference.tokens_to_generate=16384 + ++chat_template_kwargs.enable_thinking=true + server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3 --tensor-parallel-size 4" diff --git a/nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml b/nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml index cba501d..8bd2806 100644 --- a/nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml +++ b/nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml @@ -1,7 +1,7 @@ # ============================================================================ # Qwen3-4B GRPO Training Configuration # ============================================================================ -# GRPO RL training for Qwen3-4B-Instruct using NeMo-Gym equivalence_llm_judge. +# GRPO RL training for Qwen3-4B-Instruct using NeMo-Gym environments. # Inherits shared pipeline logic from base.yaml and provides all Qwen3-4B # specific settings (model paths, parallelism, etc.) # @@ -14,29 +14,73 @@ # to NeMo-Gym format, train_validation_split splits into train/val, and # prepare_data adds agent_ref routing fields. All paths are auto-wired. # -# Environment: -# Default: equivalence_llm_judge (configurable via overrides.env.nemo_gym) +# Environments: +# Inherits from base.yaml; override per-env settings below as needed. # ============================================================================ # Inherit base GRPO workflow _base_: base.yaml -# Model-specific output directory -base_output_dir: /workspace/outputs/finance/demo/workflow-5-grpo/qwen3_4b +# Run scope: change data_root AND the finance_sec_search overlay together. +# demo: data_root: /workspace/outputs/finance/demo + finance_sec_search_demo.yaml +# sap-500: data_root: /workspace/outputs/finance/sap-500 + finance_sec_search_sap500.yaml +data_root: /workspace/outputs/finance/demo + +# Model identity (define once, reference everywhere via ${hf_model_path}) +hf_model_path: /hf_models/Qwen/Qwen3-4B + +# Model-specific output directory (stages 5-9: rollouts, compute_rewards, +# train_validation_split, training, eval write here) +model_output_dir: ${base_output_dir}/qwen3_4b # ============================================================================ # Shared Anchors (define once, reuse across stages) # ============================================================================ -# GPT-OSS-120B as dedicated judge (MoE, fits on 4 GPUs). -# Used by collect_rollouts, compute_rewards, and training stages. +# GPT-OSS-120B as dedicated judge (MoE, fits on 2 GPUs). _judge_vllm: &judge_vllm model_path: /hf_models/openai/gpt-oss-120b - num_gpus: 4 + num_gpus: 2 server_nodes: 1 - max_model_len: 65536 + max_model_len: 32768 async_scheduling: true uses_reasoning_parser: true +# Per-environment sequence length for equiv/mcqa (finance_sec_search uses 131K model default) +_env_seq_len_32k: 32768 + +_training_policy_32k: &training_policy_32k + max_total_sequence_length: ${_env_seq_len_32k} + hf_config_overrides: null # No YaRN needed at 32K (native context is 40960) + megatron_cfg: + context_parallel_size: 1 # No CP needed at 32K (within native 40960 context) + generation: + vllm_cfg: + max_model_len: ${_env_seq_len_32k} + sequence_packing: + train_mb_tokens: ${_env_seq_len_32k} + logprob_mb_tokens: ${_env_seq_len_32k} + +environments: + equivalence_llm_judge: + raw_train_data: /workspace/outputs/finance/demo/workflow-3-template-based-sdg/step-5-filter-answers + judge_vllm: *judge_vllm + training_policy: *training_policy_32k + finance_sec_search: + raw_train_data: ${base_output_dir}/step-0-validate-questions/finance_sec_search + config_paths: + - resources_servers/finance_sec_search/configs/finance_sec_search.yaml + - /workspace/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_env.yaml + - /workspace/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_demo.yaml + policy_vllm: + num_gpus: 4 # 4 GPUs doubles KV cache for 131K context concurrency + max_model_len: 131072 # Extended via YaRN (native 40960); multi-turn tool calls need >40K + hf_config_overrides: # Applied via model overlay dir inside Slurm job + rope_scaling: + rope_type: yarn + factor: 3.2 # 131072 / 40960 + original_max_position_embeddings: 40960 + judge_vllm: *judge_vllm + # ============================================================================ # Model-Specific Stage Overrides # ============================================================================ @@ -44,45 +88,32 @@ _judge_vllm: &judge_vllm stages: # -------------------------------------------------------------------------- - # Data Transformation: SDG cleanup (shared) - # -------------------------------------------------------------------------- - # For demo: use the same raw SDG data as SFT. - data_transformation: - input_files: - - /workspace/outputs/finance/demo/workflow-3-template-based-sdg/step-5-filter-answers/final_result.jsonl - source_format: separated - reasoning_mode: none - - # -------------------------------------------------------------------------- - # Apply Prompt Template: format prompt + extract expected answer - # -------------------------------------------------------------------------- - # Uses secque_template.yaml (same prompt as SFT training). - # Extracts concise answer after "Answer:" for cleaner judge evaluation. - # To use a different prompt, override prompt_template here. - # apply_prompt_template: - # prompt_template: /workspace/nvflow/recipes/finance/prompts/other_template.yaml - - # -------------------------------------------------------------------------- - # Convert to Responses API: lossless format conversion + # Validate Questions: only for finance_sec_search (equivalence gets context, + # so "the company" references are unambiguous) # -------------------------------------------------------------------------- - # Input: apply_prompt_template output (prompted SDG format). - # Output: Responses API format with all original fields preserved. - # (uses base.yaml defaults -- no overrides needed) + validate_questions: + source_data: /workspace/outputs/finance/demo/workflow-3-template-based-sdg/step-5-filter-answers + environments: + finance_sec_search: ${environments.finance_sec_search} + stage_kwargs: + model: /hf_models/openai/gpt-oss-120b + server_gpus: 4 + server_args: "--max-model-len 65536 --async-scheduling --tensor-parallel-size 4" # -------------------------------------------------------------------------- - # Baseline Rollouts: Qwen3-4B configuration + # Rollout Collection: Qwen3-4B configuration # -------------------------------------------------------------------------- collect_rollouts: rollout: num_random_seeds: 8 - + num_samples_in_parallel: 64 # WORKAROUND(vllm-0.17-hermes): x86-proven value; revert to 96 after vLLM fix policy_vllm: - model_path: /hf_models/Qwen/Qwen3-4B - num_gpus: 2 - server_nodes: 1 - max_model_len: 32768 - - judge_vllm: *judge_vllm + model_path: ${hf_model_path} + num_gpus: 4 # Slurm het-group allocation (>= QOS min); TP derived from num_gpus + # Per-env HF config overrides (e.g. YaRN rope_scaling for finance_sec_search) + # are applied automatically via model overlay directories at launch time. + # See hf_config_overrides above and create_overlay.py in lib/rl/. + reasoning_parser: qwen3 # -------------------------------------------------------------------------- # Compute Rewards: re-judge with GPT-OSS-120B (optional) @@ -93,54 +124,33 @@ stages: # # Currently disabled in pipeline_stages (collect_rollouts already uses # the finance judge). To enable: uncomment "compute_rewards" above. - compute_rewards: - rejudge: - judge_vllm: *judge_vllm - - # Alternative judge configurations (uncomment one to switch): + # compute_rewards: judge_vllm is configured per-environment (see environments above). + # Alternative judge configurations can be set per-environment: # - # OpenAI API judge (API key from $OPENAI_API_KEY in container env): - # compute_rewards: - # rejudge: + # environments: + # equivalence_llm_judge: # judge_vllm: + # # OpenAI API judge: # num_gpus: 0 # openai_base_url: "https://api.openai.com/v1" # openai_model: "gpt-4o" # - # Local vLLM judge: - # compute_rewards: - # rejudge: - # judge_vllm: + # # Or local vLLM judge: # num_gpus: 4 # model_path: /hf_models/Qwen/Qwen3-30B-A3B-Instruct-2507 # -------------------------------------------------------------------------- - # Training: Qwen3-4B GRPO configuration + # Training: Qwen3-4B GRPO (equivalence_llm_judge, FSDP v2) # -------------------------------------------------------------------------- + # For finance_sec_search training, use qwen3_4b_finsec.yaml instead. training: model_name: Qwen/Qwen3-4B - hf_checkpoint_path: /hf_models/Qwen/Qwen3-4B - - # Backend: fsdp (DTensor) or megatron. - # To switch to megatron, change this to "megatron" and swap the - # parallelism block below (dtensor_cfg → megatron_cfg). + hf_checkpoint_path: ${hf_model_path} backend: fsdp - - # Cluster configuration for 4B model - num_nodes: 1 # 1 node x 8 GPUs = 8 GPUs (demo) - dependent_jobs: 0 # No dependent jobs for demo - - # Same judge as collect_rollouts (defined via anchor above). - # Modes: local_vllm (model_path), external_vllm (base_url), - # openai (openai_base_url), policy_as_judge (omit judge_vllm). - judge_vllm: *judge_vllm - - # Use base preset with model-specific overrides + total_gpus: 16 + dependent_jobs: 0 preset: "grpo-base" - # Qwen3-4B specific configuration (only non-default values) - # For demo runs, reduce max_num_steps (e.g. 2) and use max_num_samples - # in collect_rollouts to limit the dataset size. overrides: grpo: num_prompts_per_step: 16 @@ -154,49 +164,28 @@ stages: policy: tokenizer: name: ${stages.training.hf_checkpoint_path} - # Batch size = num_prompts_per_step * num_generations_per_prompt = 16 * 8 = 128 train_global_batch_size: 128 + generation: + vllm_cfg: + http_server_serving_chat_kwargs: + reasoning_parser: qwen3 - # Parallelism: TP=2 and activation_checkpointing are in the preset. - # Override here only if this model needs different values. - # - # Megatron backend (set backend: megatron above, then uncomment): - # megatron_cfg: - # enabled: true - # tensor_model_parallel_size: 2 - # pipeline_model_parallel_size: 1 - # context_parallel_size: 1 - # activation_checkpointing: true - # converter_type: "Qwen2ForCausalLM" - # dtensor_cfg: - # enabled: false - - # Logger logger: wandb: - name: "grpo-qwen3-4b" - - # Environment: equivalence_llm_judge (default from preset). - # Judge model: configured via judge_vllm above (GPT-OSS-120B). - # To change environment, override env.nemo_gym.config_paths here: - # env: - # nemo_gym: - # config_paths: - # - responses_api_models/vllm_model/configs/vllm_model_for_training.yaml - # - resources_servers/your_custom_env/configs/your_custom_env.yaml + name: "grpo-qwen3-4b-equiv" # -------------------------------------------------------------------------- - # Stage 8: Evaluate Checkpoints - Qwen3-4B GRPO + # Evaluate Checkpoints (equivalence_llm_judge) # -------------------------------------------------------------------------- + # For finance_sec_search eval, use qwen3_4b_finsec.yaml instead. eval: eval_steps: [20] - checkpoint_path: ${directories.step-7-training} - base_output_dir: ${base_output_dir} - eval_output_dir: ${base_output_dir}/step-8-eval - format: fsdp # DCP→HF conversion at eval time (same pattern as Megatron) + checkpoint_path: ${directories.step-8-training}/equivalence_llm_judge/grpo-qwen3-4b-16g-tp2-cp1-seq32k + base_output_dir: ${model_output_dir}/equivalence_llm_judge + format: fsdp # Triggers DCP/safetensors → HF conversion (auto-detects v1/v2) + base_model: ${hf_model_path} server_type: vllm - gpus: 1 - # Demo: only SecQUE and FinanceBench (skip finance_agent to save time) + gpus: 4 benchmarks: secque: seeds: 5 @@ -209,4 +198,4 @@ stages: ++inference.top_k=20 ++inference.tokens_to_generate=16384 ++chat_template_kwargs.enable_thinking=true - server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3" + server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3 --tensor-parallel-size 2" diff --git a/nvflow/recipes/finance/workflows/grpo/qwen3_4b_finsec.yaml b/nvflow/recipes/finance/workflows/grpo/qwen3_4b_finsec.yaml new file mode 100644 index 0000000..26816d2 --- /dev/null +++ b/nvflow/recipes/finance/workflows/grpo/qwen3_4b_finsec.yaml @@ -0,0 +1,144 @@ +# ============================================================================ +# Qwen3-4B GRPO Training — finance_sec_search (Megatron, 64 GPUs) +# ============================================================================ +# Single-environment config for finance_sec_search GRPO training. +# Uses Megatron backend (required for YaRN context extension to 131K). +# +# Usage: +# uv run nflow run training --config nvflow/recipes/finance/workflows/grpo/qwen3_4b_finsec.yaml +# uv run nflow run eval --config nvflow/recipes/finance/workflows/grpo/qwen3_4b_finsec.yaml +# +# Pre-requisites: +# Run data prep + rollouts + split from qwen3_4b.yaml first: +# uv run nflow run collect_rollouts --config .../qwen3_4b.yaml -e finance_sec_search +# uv run nflow run train_validation_split --config .../qwen3_4b.yaml -e finance_sec_search +# ============================================================================ + +_base_: base.yaml + +data_root: /workspace/outputs/finance/demo +hf_model_path: /hf_models/Qwen/Qwen3-4B +model_output_dir: ${base_output_dir}/qwen3_4b + +# YaRN rope scaling for 131K context (define once, reuse for vLLM + megatron) +_rope_scaling_131k: &rope_scaling_131k + rope_type: yarn + rope_theta: 1000000 + factor: 3.2 # 131072 / 40960 + original_max_position_embeddings: 40960 + truncate: true + beta_fast: 32 + beta_slow: 1 + mscale: 1 + mscale_all_dim: 0 + +# ============================================================================ +# Judge (GPT-OSS-120B, 2 GPUs) +# ============================================================================ +_judge_vllm: &judge_vllm + model_path: /hf_models/openai/gpt-oss-120b + num_gpus: 2 + server_nodes: 1 + max_model_len: 32768 + async_scheduling: true + uses_reasoning_parser: true + +# ============================================================================ +# Single Environment: finance_sec_search +# ============================================================================ +environments: + finance_sec_search: + raw_train_data: ${base_output_dir}/step-0-validate-questions/finance_sec_search + config_paths: + - resources_servers/finance_sec_search/configs/finance_sec_search.yaml + - /workspace/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_env.yaml + - /workspace/nvflow/recipes/finance/workflows/grpo/overlays/finance_sec_search_demo.yaml + policy_vllm: + num_gpus: 4 + max_model_len: 131072 + hf_config_overrides: + rope_scaling: *rope_scaling_131k + judge_vllm: *judge_vllm + +# ============================================================================ +# Stages: Training + Eval only +# ============================================================================ +stages: + + training: + model_name: Qwen/Qwen3-4B + hf_checkpoint_path: ${hf_model_path} + backend: megatron + total_gpus: 64 + dependent_jobs: 0 + preset: "grpo-base" + + overrides: + grpo: + num_prompts_per_step: 16 + num_generations_per_prompt: 8 + max_num_steps: 20 + val_period: 10 + + checkpointing: + save_period: 10 + + policy: + tokenizer: + name: ${stages.training.hf_checkpoint_path} + hf_config_overrides: + rope_scaling: *rope_scaling_131k + max_total_sequence_length: 131072 + generation: + vllm_cfg: + max_model_len: 131072 + http_server_serving_chat_kwargs: + reasoning_parser: qwen3 + train_global_batch_size: 128 + + # Megatron: TP=2, CP=8 → model_parallel=16, DP = 64/16 = 4 + # CP=8 keeps per-rank tokens at 16K (131K/8), halving logits vs CP=4. + megatron_cfg: + tensor_model_parallel_size: 2 + pipeline_model_parallel_size: 1 + context_parallel_size: 8 + sequence_parallel: true + activation_checkpointing: true + empty_unused_memory_level: 1 + defer_fp32_logits: true + env_vars: + PYTORCH_ALLOC_CONF: "expandable_segments:False" + + sequence_packing: + enabled: true + train_mb_tokens: 131072 + logprob_mb_tokens: 131072 + + logprob_chunk_size: 2048 + make_sequence_length_divisible_by: 32 # TP(2) * CP(8) * 2 + + logger: + wandb: + name: "grpo-qwen3-4b-finsec" + + eval: + eval_steps: [20] + checkpoint_path: ${directories.step-8-training}/finance_sec_search/grpo-qwen3-4b-64g-tp2-cp8-seq128k + base_output_dir: ${model_output_dir}/finance_sec_search + format: megatron + base_model: ${hf_model_path} + server_type: vllm + gpus: 4 + benchmarks: + secque: + seeds: 5 + financebench: + seeds: 5 + inference_args: >- + ++prompt_config=/workspace/nvflow/recipes/finance/prompts/secque_template.yaml + ++inference.temperature=0.6 + ++inference.top_p=0.95 + ++inference.top_k=20 + ++inference.tokens_to_generate=16384 + ++chat_template_kwargs.enable_thinking=true + server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3 --tensor-parallel-size 2" diff --git a/nvflow/recipes/finance/workflows/sdg/template-based-sdg-demo.yaml b/nvflow/recipes/finance/workflows/sdg/template-based-sdg-demo.yaml index e0d8c29..93232da 100644 --- a/nvflow/recipes/finance/workflows/sdg/template-based-sdg-demo.yaml +++ b/nvflow/recipes/finance/workflows/sdg/template-based-sdg-demo.yaml @@ -33,7 +33,7 @@ stages: dependencies: [] # -------------------------------------------------------------------------- - # Stage 1: Generate Questions - Smaller model, fewer GPUs + # Stage 1: Generate Questions - gpt-oss-20b with TP=4 # -------------------------------------------------------------------------- generate_questions: input_file: ${directories.step-0-create-seed-data}/seed_questions_demo.jsonl @@ -44,9 +44,9 @@ stages: stage_kwargs: model: /hf_models/openai/gpt-oss-20b server_type: vllm - server_gpus: 2 + server_gpus: 4 server_nodes: 1 - server_args: "--max-model-len 16384" + server_args: "--max-model-len 16384 --tensor-parallel-size 4" # -------------------------------------------------------------------------- @@ -56,43 +56,43 @@ stages: token_limit: 10000 # -------------------------------------------------------------------------- - # Stage 3: Generate Answers - Smaller model, fewer seeds + # Stage 3: Generate Answers - gpt-oss-20b with TP=4 # -------------------------------------------------------------------------- generate_answers: inline_args: "++inference.tokens_to_generate=8192 ++chat_template_kwargs.reasoning_effort=medium" stage_kwargs: model: /hf_models/openai/gpt-oss-20b server_type: vllm - server_gpus: 2 + server_gpus: 4 server_nodes: 1 num_chunks: 2 num_random_seeds: 3 - server_args: "--max-model-len 16384" + server_args: "--max-model-len 16384 --tensor-parallel-size 4" # -------------------------------------------------------------------------- - # Stage 4: GenSelect Answers - Smaller model + # Stage 4: GenSelect Answers - gpt-oss-20b with TP=4 # -------------------------------------------------------------------------- genselect_answers: inline_args: "++inference.tokens_to_generate=8192" stage_kwargs: model: /hf_models/openai/gpt-oss-20b server_type: vllm - server_gpus: 2 + server_gpus: 4 server_nodes: 1 num_chunks: 4 - server_args: "--max-model-len 16384" + server_args: "--max-model-len 16384 --tensor-parallel-size 4" # -------------------------------------------------------------------------- - # Stage 5: Filter Answers - Smaller model + # Stage 5: Filter Answers - gpt-oss-20b with TP=4 # -------------------------------------------------------------------------- filter_answers: inline_args: "++generation_key=filter_generation ++inference.tokens_to_generate=4192" stage_kwargs: model: /hf_models/openai/gpt-oss-20b server_type: vllm - server_gpus: 2 + server_gpus: 4 server_nodes: 1 num_chunks: 4 - server_args: "--max-model-len 8192" + server_args: "--max-model-len 8192 --tensor-parallel-size 4" diff --git a/nvflow/recipes/finance/workflows/sdg/template-based-sdg-smoke.yaml b/nvflow/recipes/finance/workflows/sdg/template-based-sdg-smoke.yaml new file mode 100644 index 0000000..df93c2a --- /dev/null +++ b/nvflow/recipes/finance/workflows/sdg/template-based-sdg-smoke.yaml @@ -0,0 +1,69 @@ +# ============================================================================ +# Finance SDG Pipeline - Smoke Test Configuration +# ============================================================================ +# Minimal configuration for full pipeline smoke testing. +# 2 companies, 5 seed questions, 1 random seed, 1 year. +# Inherits demo config (which inherits production config). +# +# Usage: +# uv run nflow run-all --config nvflow/recipes/finance/workflows/sdg/template-based-sdg-smoke.yaml +# ============================================================================ + +_base_: template-based-sdg-demo.yaml + +base_output_dir: /workspace/outputs/finance/smoke/workflow-3-template-based-sdg + +filings_dir: /workspace/outputs/finance/smoke/workflow-2-download-sec/step-0-download + +pipeline_stages: + - create_seed_data + - generate_questions + - map_questions_to_context + - generate_answers + - genselect_answers + +# ============================================================================ +# Smoke Test Overrides - Minimize data at every stage +# ============================================================================ +stages: + + create_seed_data: + output_file: ${directories.step-0-create-seed-data}/seed_questions_smoke.jsonl + company_info_file: ${directories.step-0-create-seed-data}/company_info_smoke.tsv + filter_company_list: "NVDA,AAPL" + num_seed_questions: 5 + dependencies: [] + + generate_questions: + input_file: ${directories.step-0-create-seed-data}/seed_questions_smoke.jsonl + company_info_file: ${directories.step-0-create-seed-data}/company_info_smoke.tsv + start_year: 2024 + end_year: 2024 + inline_args: "++chat_template_kwargs.enable_thinking=false" + stage_kwargs: + model: /hf_models/Qwen/Qwen3-4B + server_type: vllm + server_gpus: 1 + server_nodes: 1 + server_args: "--max-model-len 16384" + + generate_answers: + inline_args: "++chat_template_kwargs.enable_thinking=false" + stage_kwargs: + model: /hf_models/Qwen/Qwen3-4B + server_type: vllm + server_gpus: 1 + server_nodes: 1 + num_chunks: 1 + num_random_seeds: 1 + server_args: "--max-model-len 16384" + + genselect_answers: + inline_args: "++chat_template_kwargs.enable_thinking=false" + stage_kwargs: + model: /hf_models/Qwen/Qwen3-4B + server_type: vllm + server_gpus: 1 + server_nodes: 1 + num_chunks: 1 + server_args: "--max-model-len 16384" diff --git a/nvflow/recipes/finance/workflows/sft/base.yaml b/nvflow/recipes/finance/workflows/sft/base.yaml index c940e5c..51587a8 100644 --- a/nvflow/recipes/finance/workflows/sft/base.yaml +++ b/nvflow/recipes/finance/workflows/sft/base.yaml @@ -173,14 +173,16 @@ stages: output_dir: ${directories.step-4-training} # MODEL-SPECIFIC - Override in model configs: - # model_name, hf_checkpoint_path, num_nodes, preset, overrides + # model_name, hf_checkpoint_path, total_gpus, preset, overrides dependencies: - train_validation_split - sequence_length_grouping # Cluster configuration (shared defaults) - num_gpus: 8 + # total_gpus: total GPUs for the training job (auto-split across nodes + # using gpus_per_node from the cluster config) + total_gpus: 8 dependent_jobs: 0 backend: megatron @@ -188,9 +190,9 @@ stages: wandb_project: finance-training wandb_mode: disabled # online | offline | disabled - # Runtime environment customization (if needed) - # stage_kwargs: - # installation_command: "uv pip install some-package" + # NeMo-RL source is overlay-mounted at /opt/NeMo-RL via my_cluster.yaml. + # This provides pyproject.toml on all nodes for the Ray _env_builder, + # and resolves the UV_PROJECT=/opt/NeMo-RL path used by training.py. # -------------------------------------------------------------------------- # Stage 5: Evaluate Checkpoints on Finance Benchmarks diff --git a/nvflow/recipes/finance/workflows/sft/gemma3_1b_it.yaml b/nvflow/recipes/finance/workflows/sft/gemma3_1b_it.yaml index cee316b..7d72c47 100644 --- a/nvflow/recipes/finance/workflows/sft/gemma3_1b_it.yaml +++ b/nvflow/recipes/finance/workflows/sft/gemma3_1b_it.yaml @@ -77,7 +77,7 @@ stages: backend: fsdp # Cluster configuration for 1B model - num_nodes: 1 # 1 node × 8 GPUs = 8 GPUs (demo) + total_gpus: 8 dependent_jobs: 0 # No dependent jobs for demo # Use base preset with model-specific overrides @@ -147,7 +147,7 @@ stages: backend: megatron # Cluster configuration for 1B model - num_nodes: 1 # 1 node × 8 GPUs = 8 GPUs (demo) + total_gpus: 8 dependent_jobs: 0 # No dependent jobs for demo # Use base preset with model-specific overrides diff --git a/nvflow/recipes/finance/workflows/sft/gemma3_27b_it.yaml b/nvflow/recipes/finance/workflows/sft/gemma3_27b_it.yaml index f3ce95b..d5611f4 100644 --- a/nvflow/recipes/finance/workflows/sft/gemma3_27b_it.yaml +++ b/nvflow/recipes/finance/workflows/sft/gemma3_27b_it.yaml @@ -5,7 +5,7 @@ # Inherits shared pipeline logic from base.yaml and provides all Gemma3-27B-IT # specific settings (tokenizer, model paths, parallelism, etc.) # -# Requires 2 nodes × 8 GPUs = 16 GPUs (H100 80GB recommended) +# Requires 16 GPUs (H100 80GB recommended) # Uses FSDP2 backend with TP=8 (matches NVIDIA nightly config for Gemma3-27B) # # Usage: @@ -81,7 +81,7 @@ stages: backend: fsdp # Cluster configuration for 27B model - num_nodes: 2 # 2 nodes × 8 GPUs = 16 GPUs + total_gpus: 16 dependent_jobs: 0 # No dependent jobs # Use base preset with model-specific overrides @@ -154,7 +154,7 @@ stages: backend: megatron # Cluster configuration for 27B model - num_nodes: 2 # 2 nodes × 8 GPUs = 16 GPUs + total_gpus: 16 dependent_jobs: 0 # No dependent jobs # Use base preset with model-specific overrides diff --git a/nvflow/recipes/finance/workflows/sft/gemma3_4b_it.yaml b/nvflow/recipes/finance/workflows/sft/gemma3_4b_it.yaml index a357627..527010c 100644 --- a/nvflow/recipes/finance/workflows/sft/gemma3_4b_it.yaml +++ b/nvflow/recipes/finance/workflows/sft/gemma3_4b_it.yaml @@ -73,7 +73,7 @@ stages: backend: fsdp # Cluster configuration for 4B model - num_nodes: 1 # 1 node × 8 GPUs = 8 GPUs (demo) + total_gpus: 8 dependent_jobs: 0 # No dependent jobs for demo # Use base preset with model-specific overrides @@ -115,9 +115,6 @@ stages: activation_checkpointing: true # Works with FSDP2 (fails on Megatron for Gemma3) tensor_parallel_size: 4 - # Make sequence length divisible by TP (matches NVIDIA nightly pattern) - make_sequence_length_divisible_by: 4 - # FSDP optimizer (PyTorch native) optimizer: kwargs: @@ -126,7 +123,6 @@ stages: data: max_input_seq_length: ${stages.training.overrides.policy.max_total_sequence_length} - force_reprocess: false # -------------------------------------------------------------------------- # Stage 4b: Training - Gemma3-4B-IT configuration (Megatron backend - REFERENCE) @@ -145,7 +141,7 @@ stages: backend: megatron # Cluster configuration for 4B model - num_nodes: 1 # 1 node × 8 GPUs = 8 GPUs (demo) + total_gpus: 8 dependent_jobs: 0 # No dependent jobs for demo # Use base preset with model-specific overrides @@ -200,4 +196,3 @@ stages: data: max_input_seq_length: ${stages.training_megatron.overrides.policy.max_total_sequence_length} - force_reprocess: false diff --git a/nvflow/recipes/finance/workflows/sft/nemotron-3-nano.yaml b/nvflow/recipes/finance/workflows/sft/nemotron-3-nano.yaml index 95c68ca..9b3061d 100644 --- a/nvflow/recipes/finance/workflows/sft/nemotron-3-nano.yaml +++ b/nvflow/recipes/finance/workflows/sft/nemotron-3-nano.yaml @@ -75,7 +75,7 @@ stages: # Cluster configuration for 30B MoE model # Despite 30B total params, only 3.5B active - similar compute to smaller models - num_nodes: 32 # 32 nodes × 8 GPUs = 256 GPUs (scaled from 4 nodes) + total_gpus: 256 dependent_jobs: 4 # Chain 5 jobs total: 1 done + 4 more (to complete Epoch 1) # Use base preset with model-specific overrides @@ -228,7 +228,7 @@ stages: backend: fsdp # Cluster configuration for 30B MoE model - num_nodes: 2 # 2 nodes × 8 GPUs = 16 GPUs + total_gpus: 16 dependent_jobs: 0 # No dependent jobs for demo # Use base preset with model-specific overrides @@ -270,7 +270,7 @@ stages: # -------------------------------------------------------------------------- eval: eval_steps: [1400, 2600] - checkpoint_path: ${directories.step-4-training}/model-nvidia-nemotron-3-nano-30b-a3b-bf16-32n-tp4-pp1-cp8-seq48k + checkpoint_path: ${directories.step-4-training}/model-nvidia-nemotron-3-nano-30b-a3b-bf16-256g-tp4-pp1-cp8-seq48k base_output_dir: ${base_output_dir} format: megatron baseline_model: /hf_models/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16 diff --git a/nvflow/recipes/finance/workflows/sft/nemotron_nano_9b.yaml b/nvflow/recipes/finance/workflows/sft/nemotron_nano_9b.yaml index 45811c8..d348b1f 100644 --- a/nvflow/recipes/finance/workflows/sft/nemotron_nano_9b.yaml +++ b/nvflow/recipes/finance/workflows/sft/nemotron_nano_9b.yaml @@ -56,7 +56,7 @@ stages: backend: megatron # Cluster configuration for 9B model - num_nodes: 2 # Test: 16 GPUs (2 nodes × 8 GPUs) + total_gpus: 16 dependent_jobs: 1 # Test resume with 1 dependent job # Use base preset with model-specific overrides @@ -86,7 +86,7 @@ stages: train_micro_batch_size: 1 max_total_sequence_length: 49152 # 48K (matches max_token_length filter) - # Megatron parallelism: TP × PP × CP = 4×1×4 = 16 (must equal num_nodes × num_gpus) + # Megatron parallelism: TP × PP × CP = 4×1×4 = 16 (must equal total_gpus) megatron_cfg: tensor_model_parallel_size: 4 pipeline_model_parallel_size: 1 @@ -120,7 +120,6 @@ stages: # Debug: understand the Python environment stage_kwargs: - partition: interactive # Use interactive partition for quick testing installation_command: >- echo "=== ENVIRONMENT DEBUG ===" && echo "Which python: $(which python)" && diff --git a/nvflow/recipes/finance/workflows/sft/qwen3_14b.yaml b/nvflow/recipes/finance/workflows/sft/qwen3_14b.yaml index 44a9528..b6697d7 100644 --- a/nvflow/recipes/finance/workflows/sft/qwen3_14b.yaml +++ b/nvflow/recipes/finance/workflows/sft/qwen3_14b.yaml @@ -60,7 +60,7 @@ stages: # Cluster configuration for 14B model # Training estimates: ~2,575 steps/epoch, ~7,725 total steps (3 epochs) - num_nodes: 32 # Full training: 256 GPUs (32 nodes × 8 GPUs) + total_gpus: 256 dependent_jobs: 3 # 4 jobs per launch (~644 steps/job, ~6 ckpts/job) # Use base preset with model-specific overrides @@ -111,7 +111,6 @@ stages: lr_warmup_init: 1e-7 data: - force_reprocess: false num_workers: 10 # -------------------------------------------------------------------------- @@ -138,7 +137,7 @@ stages: # -------------------------------------------------------------------------- eval: eval_steps: [2600, 5000, 7408] - checkpoint_path: ${directories.step-4-training}/model-qwen3-14b-32n-tp4-pp1-cp8-seq48k + checkpoint_path: ${directories.step-4-training}/model-qwen3-14b-256g-tp4-pp1-cp8-seq48k base_output_dir: ${base_output_dir} format: megatron baseline_model: /hf_models/Qwen/Qwen3-14B diff --git a/nvflow/recipes/finance/workflows/sft/qwen3_4b.yaml b/nvflow/recipes/finance/workflows/sft/qwen3_4b.yaml index f23f709..c82f5c9 100644 --- a/nvflow/recipes/finance/workflows/sft/qwen3_4b.yaml +++ b/nvflow/recipes/finance/workflows/sft/qwen3_4b.yaml @@ -11,6 +11,9 @@ # Inherit base SFT workflow _base_: base.yaml +# Model identity (define once, reference everywhere via ${hf_model_path}) +hf_model_path: /hf_models/Qwen/Qwen3-4B + # Model-specific output directory base_output_dir: /workspace/outputs/finance/demo/workflow-4-sft/qwen3_4b @@ -40,7 +43,7 @@ stages: prepare_for_sft: prepare_data_kwargs: ctx_args: >- - ++tokenizer=/hf_models/Qwen/Qwen3-4B + ++tokenizer=${hf_model_path} ++prompt_config=nvflow/recipes/finance/prompts/secque_template.yaml ++chat_template_kwargs.enable_thinking=true @@ -48,21 +51,20 @@ stages: # Stage 3: Sequence Length Grouping - Qwen3-4B tokenizer # -------------------------------------------------------------------------- sequence_length_grouping: - tokenizer_path: /hf_models/Qwen/Qwen3-4B + tokenizer_path: ${hf_model_path} # -------------------------------------------------------------------------- # Stage 4: Training - Qwen3-4B configuration (NeMo-RL format) # -------------------------------------------------------------------------- training: model_name: Qwen/Qwen3-4B - hf_checkpoint_path: /hf_models/Qwen/Qwen3-4B + hf_checkpoint_path: ${hf_model_path} # Use Megatron backend (standard for Qwen3-4B) backend: megatron # Cluster configuration for 4B model - # Training estimates: TBD (depends on dataset size) - num_nodes: 1 # 1 node × 8 GPUs = 8 GPUs (demo) + total_gpus: 8 dependent_jobs: 0 # No dependent jobs for demo # Use base preset with model-specific overrides @@ -86,7 +88,7 @@ stages: enabled: true train_mb_tokens: 32768 # Match max_total_sequence_length - # Megatron parallelism: TP × PP × CP = 2×1×2 = 4 → DP = 16/4 = 4 + # Megatron parallelism: TP × PP × CP = 2×1×2 = 4 → DP = 8/4 = 2 megatron_cfg: tensor_model_parallel_size: 2 context_parallel_size: 2 # 32K/2 = 16K tokens/rank @@ -103,20 +105,19 @@ stages: data: max_input_seq_length: ${stages.training.overrides.policy.max_total_sequence_length} - force_reprocess: false # -------------------------------------------------------------------------- # Stage 5: Evaluate Checkpoints - Qwen3-4B # -------------------------------------------------------------------------- eval: eval_steps: [10] - checkpoint_path: ${directories.step-4-training}/model-qwen3-4b-1n-tp2-pp1-cp2-seq32k + checkpoint_path: ${directories.step-4-training}/model-qwen3-4b-8g-tp2-pp1-cp2-seq32k base_output_dir: ${base_output_dir} format: megatron - base_model: /hf_models/Qwen/Qwen3-4B + base_model: ${hf_model_path} server_type: vllm - gpus: 1 - # Demo: only SecQUE and FinanceBench (skip finance_agent to save time) + gpus: 4 # Slurm allocation (>= QOS min); override TP via --tensor-parallel-size in server_args + # Demo: SecQUE and FinanceBench (skip finance_agent to save time) benchmarks: secque: seeds: 5 @@ -129,4 +130,4 @@ stages: ++inference.top_k=20 ++inference.tokens_to_generate=16384 ++chat_template_kwargs.enable_thinking=true - server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3" + server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3 --tensor-parallel-size 2" diff --git a/nvflow/recipes/finance/workflows/sft/qwen3_4b_smoke.yaml b/nvflow/recipes/finance/workflows/sft/qwen3_4b_smoke.yaml new file mode 100644 index 0000000..4c24561 --- /dev/null +++ b/nvflow/recipes/finance/workflows/sft/qwen3_4b_smoke.yaml @@ -0,0 +1,50 @@ +# ============================================================================ +# Qwen3-4B SFT - Smoke Test Configuration +# ============================================================================ +# Minimal SFT config for full pipeline smoke testing. +# 1 epoch, SecQUE eval with 1 seed. Points to smoke SDG output. +# +# Usage: +# uv run nflow run-all --config nvflow/recipes/finance/workflows/sft/qwen3_4b_smoke.yaml +# ============================================================================ + +_base_: qwen3_4b.yaml + +base_output_dir: /workspace/outputs/finance/smoke/workflow-4-sft/qwen3_4b + +directories: + raw_train_data: /workspace/outputs/finance/smoke/workflow-3-template-based-sdg/step-4-genselect-answers + +# ============================================================================ +# Smoke Test Overrides +# ============================================================================ +stages: + + training: + overrides: + sft: + max_num_epochs: 1 + checkpointing: + save_period: 1 + policy: + train_global_batch_size: 4 + + eval: + eval_steps: ["final"] + checkpoint_path: ${directories.step-4-training}/model-qwen3-4b-1n-tp2-pp1-cp2-seq32k + base_output_dir: ${base_output_dir} + format: megatron + base_model: /hf_models/Qwen/Qwen3-4B + server_type: vllm + gpus: 1 + benchmarks: + secque: + seeds: 1 + inference_args: >- + ++prompt_config=/workspace/nvflow/recipes/finance/prompts/secque_template.yaml + ++inference.temperature=0.6 + ++inference.top_p=0.95 + ++inference.top_k=20 + ++inference.tokens_to_generate=16384 + ++chat_template_kwargs.enable_thinking=true + server_args: "--max-model-len 40960 --async-scheduling --reasoning-parser qwen3" diff --git a/nvflow/recipes/finance/workflows/sft/sft_presets.yaml b/nvflow/recipes/finance/workflows/sft/sft_presets.yaml index ae1add8..1e0b961 100644 --- a/nvflow/recipes/finance/workflows/sft/sft_presets.yaml +++ b/nvflow/recipes/finance/workflows/sft/sft_presets.yaml @@ -1,87 +1,108 @@ # ============================================================================ -# SFT Model Presets (NeMo-RL Native Format) +# SFT Training Presets - NeMo-RL Direct (Full Preset) # ============================================================================ -# All presets use NeMo-RL's native config structure for direct pass-through -# to nemo_rl.algorithms.sft.SFTTrainer via start_sft.py. +# Model-independent default configurations for SFT training. +# These presets are merged with model-specific overrides in workflow YAMLs. # -# Design Philosophy (matches NeMo-RL): -# - Base preset contains model-independent defaults -# - Model-specific overrides go in workflow YAML files +# This is a FULL preset -- every parameter we pass is explicit. +# Model configs (e.g., qwen3_4b.yaml) override model-specific values. # -# NeMo-RL pattern: -# sft.yaml (base) → sft-nanov3.yaml (workflow overrides) +# Aligned with NeMo-RL's examples/configs/sft.yaml +# targeting NeMo-RL main branch (mounted as overlay at /opt/NeMo-RL). # -# Our pattern: -# sft-base preset → {model}_workflow.yaml (workflow overrides) +# Deviations from the NeMo-RL example are marked with: +# [FINANCE] -- intentional change for our setup +# [MANAGED] -- key is set at runtime via ++key=value CLI args +# (last-write-wins) by training.py _build_train_cmd() +# +# The full preset is written as a YAML file and passed to +# run_sft.py via --config. Runtime overrides +# (model_name, cluster, checkpoint_dir, data paths, etc.) are applied +# on top via ++key=value CLI args by training.py. # ============================================================================ presets: # ========================================================================== - # Base Preset (model-independent defaults, matches NeMo-RL sft.yaml) + # Base Preset (model-independent defaults) # ========================================================================== - # Use this preset for all new models and provide model-specific overrides - # in your workflow YAML file. - # # Example usage in workflow: # training: # preset: "sft-base" - # backend: fsdp + # backend: megatron # overrides: # policy: # max_total_sequence_length: 49152 - # dtensor_cfg: - # tensor_parallel_size: 2 + # megatron_cfg: + # tensor_model_parallel_size: 4 # ========================================================================== sft-base: - description: "Base SFT configuration - model-independent defaults (matches NeMo-RL v0.5.0 schema)" - - # Training algorithm settings + # ======================================================================== + # SFT Algorithm Configuration + # ======================================================================== sft: max_num_epochs: 1 max_num_steps: 1000000 val_period: 100 + val_batches: 8 + val_global_batch_size: 32 + val_micro_batch_size: 1 val_at_start: true + val_at_end: true + seed: 42 - # Checkpointing (NEW FORMAT - use val:metric_name) + # ======================================================================== + # Checkpointing Configuration + # ======================================================================== checkpointing: enabled: true - save_period: 100 + # [MANAGED] checkpoint_dir and checkpoint_must_save_by are set by + # training.py _build_train_cmd() at runtime via CLI args. + checkpoint_dir: "results/sft" + checkpoint_must_save_by: null + metric_name: "val:val_loss" + higher_is_better: false keep_top_k: 50 - metric_name: "val:val_loss" # NEW: prefix with "val:" or "train:" + save_period: 100 + save_optimizer: true - # Policy (model) configuration + # ======================================================================== + # Policy Configuration (Model + Training) + # ======================================================================== policy: + # [MANAGED] model_name is set by training.py from hf_checkpoint_path. + model_name: null # e.g., "Qwen/Qwen3-4B" + tokenizer: + name: ${policy.model_name} # OmegaConf resolver -- resolved after ++policy.model_name override + chat_template: null # Passthrough: our data has pre-formatted <|im_start|> tags + chat_template_kwargs: null + train_global_batch_size: 128 train_micro_batch_size: 1 max_total_sequence_length: 4096 precision: "bfloat16" max_grad_norm: 1.0 - - # NEW: Makes sequence length divisible by TP for sequence parallel + # [MANAGED] Auto-computed by training.py from CP, TP, SP settings. + # Ensures sequences are padded for correct CP/SP splitting. + # User overrides to higher values (e.g., FP8 alignment) are preserved. make_sequence_length_divisible_by: 1 - - # NEW: Optional optimizer offload for logprob computation offload_optimizer_for_logprob: false # ---------------------------------------------------------------------- # FSDP/DTensor configuration (used when backend=fsdp) - # NEW FIELDS: _v2, env_vars, lora_cfg # ---------------------------------------------------------------------- dtensor_cfg: - _v2: true # NEW: Version flag for new schema - # enabled field removed - nemo-skills handles backend switching - env_vars: {} # NEW: Environment variables for DTensor + _v2: true + env_vars: {} cpu_offload: false tensor_parallel_size: 1 context_parallel_size: 1 - expert_parallel_size: 1 # NEW: For MoE models + expert_parallel_size: 1 sequence_parallel: false activation_checkpointing: false custom_parallel_plan: null - # NEW: LoRA configuration for FSDP lora_cfg: enabled: false target_modules: [] @@ -94,27 +115,23 @@ presets: lora_A_init: "xavier" use_triton: true - # NEW: Dynamic batching configuration dynamic_batching: enabled: false - train_mb_tokens: 4096 # Will be overridden by OmegaConf resolver + train_mb_tokens: 4096 sequence_length_round: 64 - # NEW: Sequence packing configuration sequence_packing: enabled: false - train_mb_tokens: 4096 # Will be overridden by OmegaConf resolver + train_mb_tokens: 4096 algorithm: "modified_first_fit_decreasing" sequence_length_round: 64 # ---------------------------------------------------------------------- # Megatron configuration (used when backend=megatron) - # NEW FIELDS: env_vars, empty_unused_memory_level, peft # ---------------------------------------------------------------------- megatron_cfg: - # enabled field removed - nemo-skills handles backend switching - env_vars: {} # NEW: Environment variables for Megatron - empty_unused_memory_level: 1 # NEW: 1 is safer (was 0 in old schema) + env_vars: {} + empty_unused_memory_level: 1 activation_checkpointing: false tensor_model_parallel_size: 1 pipeline_model_parallel_size: 1 @@ -126,10 +143,9 @@ presets: num_layers_in_first_pipeline_stage: null num_layers_in_last_pipeline_stage: null freeze_moe_router: false - # MoE parameters (safe defaults for both MoE and dense models) - moe_router_dtype: "fp32" # Changed from null to fp32 - moe_router_load_balancing_type: "none" # Changed from "aux_loss" (has Megatron bug) - moe_aux_loss_coeff: 0.0 # Added: required with aux_loss disabled + moe_router_dtype: "fp32" + moe_router_load_balancing_type: "none" + moe_aux_loss_coeff: 0.0 moe_router_bias_update_rate: 1e-3 moe_permute_fusion: false apply_rope_fusion: true @@ -140,8 +156,10 @@ presets: moe_enable_deepep: false moe_token_dispatcher_type: "allgather" moe_shared_expert_overlap: false + gradient_accumulation_fusion: false # Required by v0.6.0 community_import.py + use_linear_ce_fusion_loss: false + linear_ce_fusion_chunk_size: 256 - # NEW: PEFT/LoRA configuration for Megatron peft: enabled: false target_modules: [] @@ -155,7 +173,12 @@ presets: a2a_experimental: false lora_dtype: null - # Megatron optimizer + fp8_cfg: + enabled: false + fp8: "e4m3" + fp8_recipe: "blockwise" + fp8_param: false + optimizer: optimizer: "adam" lr: 5.0e-6 @@ -163,28 +186,26 @@ presets: weight_decay: 0.01 bf16: true fp16: false - # params_dtype removed - SFT uses model dtype (bfloat16), not float32 + params_dtype: "float32" adam_beta1: 0.9 adam_beta2: 0.98 adam_eps: 1e-5 sgd_momentum: 0.9 use_distributed_optimizer: true - # use_precision_aware_optimizer removed - GRPO-only setting + use_precision_aware_optimizer: true clip_grad: 1.0 optimizer_cpu_offload: false optimizer_offload_fraction: 0.0 - # Megatron scheduler (default: cosine decay for SFT) scheduler: start_weight_decay: 0.01 end_weight_decay: 0.01 weight_decay_incr_style: "constant" - lr_decay_style: "cosine" # SFT uses cosine (GRPO uses constant) + lr_decay_style: "cosine" lr_decay_iters: 1000 - lr_warmup_iters: 100 # Standard SFT warmup (was 0) - lr_warmup_init: 1e-7 # Warmup from small value (was 5e-6) + lr_warmup_iters: 100 + lr_warmup_init: 1e-7 - # Megatron DDP config distributed_data_parallel_config: grad_reduce_in_fp32: false overlap_grad_reduce: true @@ -194,7 +215,6 @@ presets: # ---------------------------------------------------------------------- # FSDP Optimizer (PyTorch native, at policy level) - # CRITICAL: This is REQUIRED for FSDP backend! # ---------------------------------------------------------------------- optimizer: name: "torch.optim.AdamW" @@ -203,27 +223,31 @@ presets: weight_decay: 0.1 betas: [0.9, 0.98] eps: 1e-5 - foreach: false # Must be false for DTensor - fused: false # Must be false for DTensor + foreach: false + fused: false # ---------------------------------------------------------------------- # FSDP Scheduler (PyTorch native, at policy level) # Default: Linear warmup (2000 steps) + Cosine annealing decay - # Override in workflow YAML if different schedule needed # ---------------------------------------------------------------------- scheduler: - name: "torch.optim.lr_scheduler.LinearLR" kwargs: - start_factor: 0.033 # Warmup from ~1e-7 to full LR + start_factor: 0.033 end_factor: 1.0 - total_iters: 2000 # 2000 step warmup + total_iters: 2000 - name: "torch.optim.lr_scheduler.CosineAnnealingLR" kwargs: - T_max: 100000 # Total training steps - eta_min: 5.0e-7 # Min LR (10x lower than base) - - milestones: [0] # Start warmup immediately + T_max: 100000 + eta_min: 5.0e-7 + - milestones: [0] - # Data settings + # ======================================================================== + # Data Configuration (NeMo-RL main format) + # ======================================================================== + # train.data_path and validation.data_path are injected at runtime + # via ++data.train.data_path=... by training.py _build_train_cmd(). + # When no validation data, training.py sets ++data.validation=null. data: max_input_seq_length: 4096 add_bos: true @@ -231,3 +255,44 @@ presets: add_generation_prompt: false shuffle: true num_workers: 10 + train: {} + validation: {} + default: + dataset_name: ResponseDataset + input_key: input + output_key: output + processor: "sft_processor" + + # ======================================================================== + # Logger Configuration + # ======================================================================== + # [MANAGED] log_dir is set by training.py at runtime. + logger: + log_dir: "logs" + wandb_enabled: false + swanlab_enabled: false + tensorboard_enabled: true + mlflow_enabled: false + monitor_gpus: true + wandb: + project: "finance-sft" + name: "sft-training" + swanlab: + project: "finance-sft" + name: "sft-training" + tensorboard: {} + mlflow: + experiment_name: "finance-sft" + run_name: "sft-training" + gpu_monitoring: + collection_interval: 10 + flush_interval: 10 + + # ======================================================================== + # Cluster Configuration + # ======================================================================== + # [MANAGED] cluster.gpus_per_node and cluster.num_nodes are set by + # training.py _build_train_cmd() at runtime via CLI args. + cluster: + gpus_per_node: 8 + num_nodes: 1 diff --git a/pyproject.toml b/pyproject.toml index 06cbdf0..eadd667 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,9 @@ dependencies = [ # NeMo-Skills for cluster job submission (brings ~200+ dependencies) # This is the core framework we orchestrate - list it first for clarity # Pinned to specific commit for reproducibility (nemo-skills doesn't use version tags) - # Pinned on: 2026-02-08 | Commit: 7d6c49a5 (latest main, includes extra_body dict fix #1195) + # Pinned on: 2026-05-05 | Commit: 0229040 (consistent with nemo-skills:0229040 container) # To update: Find commit with containers in cluster_configs/example-slurm.yaml - "nemo-skills @ git+https://github.com/NVIDIA/NeMo-Skills.git@7d6c49a51efb441b61db3e78f6ffa2f04c9a68ef", + "nemo-skills @ git+https://github.com/NVIDIA/NeMo-Skills.git@022904023ad7a83a87662a313cf72e7df5891d55", # Configuration & Workflow "omegaconf>=2.3.0", # YAML config loading with variable interpolation @@ -27,9 +27,6 @@ dependencies = [ # Data handling "jsonlines>=4.0.0", # JSONL file reading/writing - # BDSA vulnerability fixes (transitive dependency pins) - "gradio>=6.9.0", - "Pillow>=12.1.1", # urllib3>=2.6.3 blocked by torchx<1.27 constraint (via nemo-skills → nemo-run → torchx) # Note: Heavy dependencies (torch, transformers, etc.) come from nemo-skills @@ -40,7 +37,7 @@ dependencies = [ # Development tools (testing, linting, formatting, docs) dev = [ # Testing - "pytest>=7.4.0", + "pytest>=9.0.3", "pytest-cov>=4.1.0", "pytest-xdist>=3.3.0", "pytest-timeout>=2.2.0", @@ -102,7 +99,17 @@ timeout = 300 [tool.uv] managed = true override-dependencies = [ + # Break leptonai's httpx[http2]==0.27.2 hard pin (pulled in via nemo-run) + # so litellm 1.83.14 (which requires httpx==0.28.1) can be installed. + # Mirrors the override added in nemo-skills/pyproject.toml (PR #1433), + # but [tool.uv] from a dep is ignored — overrides must be at the top-level project. + "httpx[http2]>=0.28.1", "urllib3>=2.6.3", + # Force minimum versions of transitive deps for security/maintenance updates + "cryptography>=47.0.0", + "Pillow>=12.2.0", + "Pygments>=2.20.0", + "GitPython>=3.1.49", ] [dependency-groups] diff --git a/scripts/check_dco.py b/scripts/check_dco.py index 9485186..03e6fd2 100644 --- a/scripts/check_dco.py +++ b/scripts/check_dco.py @@ -55,8 +55,11 @@ def main() -> int: # Try to resolve the base ref; on detached HEAD (post-merge pipelines on # main) the local branch ref may not exist, so fetch origin/ as # fallback reference. + # --first-parent: only check direct commits on the branch, not commits + # that came in via merged feature branches. This avoids re-checking + # historical unsigned commits from old MRs during cross-branch merges. result = subprocess.run( - ["git", "rev-list", "--no-merges", f"{base}..HEAD"], + ["git", "rev-list", "--no-merges", "--first-parent", f"{base}..HEAD"], capture_output=True, text=True, timeout=10, @@ -64,7 +67,7 @@ def main() -> int: if result.returncode != 0: # base ref not found locally – try origin/ result = subprocess.run( - ["git", "rev-list", "--no-merges", f"origin/{base}..HEAD"], + ["git", "rev-list", "--no-merges", "--first-parent", f"origin/{base}..HEAD"], capture_output=True, text=True, timeout=10, diff --git a/scripts/convert_checkpoint_to_hf.sh b/scripts/convert_checkpoint_to_hf.sh new file mode 100755 index 0000000..f53437a --- /dev/null +++ b/scripts/convert_checkpoint_to_hf.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# ============================================================================ +# Convert Training Checkpoint to HuggingFace Format +# ============================================================================ +# Converts a Megatron or DTensor checkpoint to HuggingFace format. +# +# Supported backends: +# megatron (default): Megatron DCP checkpoints (.distcp shards) +# Uses checkpoint_converter.py which auto-copies +# tokenizer/config from the base model. +# dtensor: DTensor/FSDP checkpoints +# Uses NeMo-RL's convert_dcp_to_hf.py + tokenizer rsync. +# +# Usage: +# ./scripts/convert_checkpoint_to_hf.sh [OPTIONS] +# +# Required: +# --model-name NAME HF model name or path (e.g., Qwen/Qwen3-30B-A3B) +# --checkpoint-dir PATH Path to checkpoints dir (contains step_N/) +# --step NUMBER Step number to convert +# +# Optional: +# --backend BACKEND megatron (default) or dtensor +# --output-dir PATH Output path (default: ../manual_conversion/hf/step_N) +# --cluster NAME Cluster config name (default: my_cluster) +# --num-gpus N GPUs for conversion job (default: 4) +# +# Examples: +# # Convert Megatron checkpoint (GSPO Qwen3-30B-A3B) +# ./scripts/convert_checkpoint_to_hf.sh \ +# --model-name /hf_models/Qwen/Qwen3-30B-A3B \ +# --checkpoint-dir /workspace/outputs/.../checkpoints \ +# --step 18 +# +# # Convert DTensor checkpoint +# ./scripts/convert_checkpoint_to_hf.sh \ +# --model-name Qwen/Qwen3-14B \ +# --checkpoint-dir /workspace/outputs/.../checkpoints \ +# --step 500 \ +# --backend dtensor +# ============================================================================ + +set -euo pipefail + +# ============================================================================ +# Parse Arguments +# ============================================================================ +MODEL_NAME="" +CHECKPOINT_DIR="" +STEP_NUMBER="" +OUTPUT_DIR="" +BACKEND="megatron" +CLUSTER="my_cluster" +NUM_GPUS=4 + +while [[ $# -gt 0 ]]; do + case $1 in + --model-name) MODEL_NAME="$2"; shift 2 ;; + --checkpoint-dir) CHECKPOINT_DIR="$2"; shift 2 ;; + --step) STEP_NUMBER="$2"; shift 2 ;; + --output-dir) OUTPUT_DIR="$2"; shift 2 ;; + --backend) BACKEND="$2"; shift 2 ;; + --cluster) CLUSTER="$2"; shift 2 ;; + --num-gpus) NUM_GPUS="$2"; shift 2 ;; + -h|--help) head -41 "$0" | tail -40; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# Validate required args +for arg_name in MODEL_NAME CHECKPOINT_DIR STEP_NUMBER; do + if [[ -z "${!arg_name}" ]]; then + echo "Error: --$(echo "$arg_name" | tr '_' '-' | tr '[:upper:]' '[:lower:]') is required" >&2 + echo "Run with --help for usage" >&2 + exit 1 + fi +done + +if [[ "$BACKEND" != "megatron" && "$BACKEND" != "dtensor" ]]; then + echo "Error: --backend must be 'megatron' or 'dtensor'" >&2 + exit 1 +fi + +# Derive paths +STEP_PATH="${CHECKPOINT_DIR}/step_${STEP_NUMBER}" +OUTPUT_DIR="${OUTPUT_DIR:-$(dirname "$CHECKPOINT_DIR")/manual_conversion/hf/step_${STEP_NUMBER}}" +LOG_DIR="$(dirname "$CHECKPOINT_DIR")/manual_conversion/logs/step_${STEP_NUMBER}" +EXPNAME="convert-$(basename "$(dirname "$CHECKPOINT_DIR")")-step${STEP_NUMBER}" + +echo "============================================" +echo "Checkpoint to HuggingFace Conversion" +echo "============================================" +echo "" +echo " Model: ${MODEL_NAME}" +echo " Backend: ${BACKEND}" +echo " Step: ${STEP_NUMBER}" +echo " Input: ${STEP_PATH}" +echo " Output: ${OUTPUT_DIR}" +echo " Cluster: ${CLUSTER}" +echo " GPUs: ${NUM_GPUS}" +echo "" + +# ============================================================================ +# Build Conversion Command +# ============================================================================ +cd "$(dirname "$0")/.." + +if [[ "$BACKEND" == "megatron" ]]; then + # checkpoint_converter.py handles: + # 1. Megatron DCP -> HF weight conversion + # 2. Auto-copy tokenizer/config from base model (fixes Bridge corruption) + # 3. Idempotent skip if output already exists + CONVERT_CALL="convert_checkpoint(" + CONVERT_CALL+="megatron_path=\\\"${STEP_PATH}\\\", " + CONVERT_CALL+="hf_output_path=\\\"${OUTPUT_DIR}\\\", " + CONVERT_CALL+="model_name=\\\"${MODEL_NAME}\\\")" + + FULL_CMD="export UV_PROJECT=/opt/NeMo-RL \ + && export PYTHONPATH=\$PYTHONPATH:/nemo_run/code \ + && uv run --extra mcore python -c \ + \"from nvflow.recipes.finance.utils.evaluation.checkpoint_converter import convert_checkpoint; ${CONVERT_CALL}\"" +else + # DTensor/FSDP: NeMo-RL's converter + rsync tokenizer from checkpoint + FULL_CMD="export UV_PROJECT=/opt/NeMo-RL \ + && cd /opt/NeMo-RL \ + && uv run examples/converters/convert_dcp_to_hf.py \ + --config=\"${STEP_PATH}/config.yaml\" \ + --dcp-ckpt-path=\"${STEP_PATH}/policy/weights\" \ + --hf-ckpt-path=\"${OUTPUT_DIR}\" \ + && rsync -ahP \"${STEP_PATH}/policy/tokenizer/\" \"${OUTPUT_DIR}/\"" +fi + +# ============================================================================ +# Submit Job +# ============================================================================ +echo "Submitting conversion job..." +echo "" + +uv run ns run_cmd \ + --cluster "${CLUSTER}" \ + --num_gpus "${NUM_GPUS}" \ + --container "nemo-rl" \ + --expname "${EXPNAME}" \ + --log_dir "${LOG_DIR}" \ + --command "${FULL_CMD}" + +echo "" +echo "============================================" +echo "Job submitted!" +echo "============================================" +echo "" +echo "Monitor logs at: ${LOG_DIR}" +echo "Output HF model: ${OUTPUT_DIR}" +echo "" diff --git a/scripts/convert_megatron_to_hf.sh b/scripts/convert_megatron_to_hf.sh deleted file mode 100755 index cdc5bfa..0000000 --- a/scripts/convert_megatron_to_hf.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/bin/bash -# ============================================================================ -# Convert Megatron Checkpoint to HuggingFace Format -# ============================================================================ -# This script manually converts a Megatron checkpoint to HuggingFace format -# using ns run_cmd. It bypasses the hf_overrides argument that causes failures -# in nemo-rl 0.7.1 containers. -# -# Usage: -# ./scripts/convert_megatron_to_hf.sh [STEP_NUMBER] -# -# Examples: -# ./scripts/convert_megatron_to_hf.sh # Convert latest checkpoint (step_606) -# ./scripts/convert_megatron_to_hf.sh 500 # Convert step_500 checkpoint -# -# ============================================================================ - -set -euo pipefail - -# ============================================================================ -# Configuration - Modify these for your run -# ============================================================================ - -# Model configuration -MODEL_NAME="Qwen/Qwen3-14B" -TOKENIZER_PATH="Qwen/Qwen3-14B" - -# Paths (using /workspace which maps to the project root in container) -RUN_NAME="model-qwen3-14b-32n-tp4-pp1-cp16-seq64k" -BASE_DIR="/workspace/outputs/finance/sft/step-5-sft/${RUN_NAME}" -CHECKPOINT_DIR="${BASE_DIR}/checkpoints" - -# Default to step_606 (final checkpoint) if not specified -STEP_NUMBER="${1:-606}" -INPUT_PATH="${CHECKPOINT_DIR}/step_${STEP_NUMBER}/policy/weights/iter_0000000" - -# Output directories (organized under manual_conversion/) -OUTPUT_PATH="${BASE_DIR}/manual_conversion/hf/step_${STEP_NUMBER}" -LOG_DIR="${BASE_DIR}/manual_conversion/logs/step_${STEP_NUMBER}" - -# Cluster configuration -CLUSTER="nrt" -NUM_GPUS=8 -CONTAINER="nemo-rl" -EXPNAME="convert-${RUN_NAME}-step${STEP_NUMBER}" - -# ============================================================================ -# Script Logic -# ============================================================================ - -echo "============================================" -echo "Megatron to HuggingFace Conversion" -echo "============================================" -echo "" -echo "Configuration:" -echo " Model: ${MODEL_NAME}" -echo " Step: ${STEP_NUMBER}" -echo " Input: ${INPUT_PATH}" -echo " Output: ${OUTPUT_PATH}" -echo " Cluster: ${CLUSTER}" -echo " GPUs: ${NUM_GPUS}" -echo " Experiment: ${EXPNAME}" -echo " Log Dir: ${LOG_DIR}" -echo "" - -# Change to project root -cd "$(dirname "$0")/.." - -# Build the Python conversion command -# Note: We explicitly DO NOT pass hf_overrides to avoid the version mismatch error -# in the container's megatron-bridge library -# Using escaped quotes to preserve them through shell expansion -PYTHON_CODE="from nemo_rl.models.megatron.community_import import export_model_from_megatron; export_model_from_megatron(hf_model_name=\\\"${MODEL_NAME}\\\", input_path=\\\"${INPUT_PATH}\\\", output_path=\\\"${OUTPUT_PATH}\\\", hf_tokenizer_path=\\\"${TOKENIZER_PATH}\\\", overwrite=True)" - -echo "Submitting conversion job..." -echo "" - -# Build the full command matching the original job setup: -# - Set UV_PROJECT to /opt/NeMo-RL (where nemo-rl source is) -# - Use uv run --extra mcore to get megatron dependencies -# - Run python with our conversion code -FULL_CMD="export UV_PROJECT=/opt/NeMo-RL && uv run --extra mcore python -c \"${PYTHON_CODE}\"" - -# Run the conversion via ns run_cmd -uv run ns run_cmd \ - --cluster "${CLUSTER}" \ - --num_gpus "${NUM_GPUS}" \ - --container "${CONTAINER}" \ - --expname "${EXPNAME}" \ - --log_dir "${LOG_DIR}" \ - --command "${FULL_CMD}" - -echo "" -echo "============================================" -echo "Job submitted!" -echo "============================================" -echo "" -echo "Monitor logs at:" -echo " ${LOG_DIR/\/workspace\//./}/" -echo "" -echo "Or check nemo-run job status:" -echo " ls -la outputs/jobs/nemo-run/${EXPNAME}/" -echo "" -echo "Output HF model will be saved to:" -echo " ${OUTPUT_PATH/\/workspace\//./}/" -echo "" diff --git a/scripts/convert_to_profiled.py b/scripts/convert_to_profiled.py new file mode 100644 index 0000000..2e9c0a6 --- /dev/null +++ b/scripts/convert_to_profiled.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Convert nvflow filtered train.jsonl + rollout outputs to the profiled format. + +Reads: + - train.jsonl (filtered, with difficulty_profile from aggregate_seeds) + - output-rs*.jsonl (merged rollout outputs with response.usage token counts) + +Produces per-task records with: + - Original input fields (responses_create_params, expected_answer, template_metadata, agent_ref) + - pass_rate, pass_rate_passed, pass_rate_total + - Token count metrics: input_tokens/{mean,std,max,min}, output_tokens/..., total_tokens/... + +Usage: + python3 scripts/convert_to_profiled.py \ + --train-path \ + --rollout-dir \ + --output-path +""" + +import argparse +import json +import sys +from collections import defaultdict +from pathlib import Path + +import numpy as np + + +def main(): + parser = argparse.ArgumentParser(description="Convert to profiled format with token counts") + parser.add_argument("--train-path", required=True, help="Filtered train.jsonl") + parser.add_argument( + "--rollout-dir", required=True, help="Directory with output-rs*.jsonl files" + ) + parser.add_argument("--output-path", required=True, help="Output profiled jsonl") + parser.add_argument("--dry-run", action="store_true", help="Process first 1000 records only") + args = parser.parse_args() + + rollout_dir = Path(args.rollout_dir) + rollout_files = sorted(rollout_dir.glob("output-rs*.jsonl")) + if not rollout_files: + print(f"ERROR: No output-rs*.jsonl files in {rollout_dir}", file=sys.stderr) + sys.exit(1) + print(f"Found {len(rollout_files)} rollout files: {[f.name for f in rollout_files]}") + + # Step 1: Load train.jsonl (filtered) — these are the tasks we want + print("Loading train.jsonl...") + train_by_uuid = {} + with open(args.train_path) as f: + for line in f: + rec = json.loads(line) + uid = rec.get("uuid", "") + if uid: + train_by_uuid[uid] = rec + print(f" {len(train_by_uuid)} tasks loaded") + + # Step 2: Stream through rollout files, collect token counts per uuid + print("Streaming rollout files for token counts...") + token_stats = defaultdict(lambda: {"input": [], "output": [], "total": [], "rewards": []}) + total_rows = 0 + + for rf in rollout_files: + print(f" Reading {rf.name}...") + with open(rf) as f: + for line in f: + row = json.loads(line) + uid = row.get("uuid", "") + if not uid or uid not in train_by_uuid: + continue + + reward = row.get("reward", 0.0) + token_stats[uid]["rewards"].append(reward) + + usage = row.get("response", {}).get("usage") + if usage: + token_stats[uid]["input"].append(usage.get("input_tokens", 0)) + token_stats[uid]["output"].append(usage.get("output_tokens", 0)) + token_stats[uid]["total"].append(usage.get("total_tokens", 0)) + + total_rows += 1 + if args.dry_run and total_rows >= 10000: + break + if args.dry_run and total_rows >= 10000: + break + + print(f" {total_rows} rollout rows matched, {len(token_stats)} unique tasks") + + # Step 3: Build profiled output + print("Writing profiled output...") + written = 0 + skipped_no_tokens = 0 + + with open(args.output_path, "w") as out: + for uid, train_rec in train_by_uuid.items(): + stats = token_stats.get(uid) + if not stats or not stats["input"]: + skipped_no_tokens += 1 + continue + + rewards = stats["rewards"] + pass_rate_passed = sum(r for r in rewards) + pass_rate_total = len(rewards) + pass_rate = pass_rate_passed / pass_rate_total if pass_rate_total > 0 else 0.0 + + profiled = { + "responses_create_params": train_rec["responses_create_params"], + "expected_answer": train_rec.get("expected_answer", ""), + } + if "template_metadata" in train_rec: + profiled["template_metadata"] = train_rec["template_metadata"] + if "agent_ref" in train_rec: + profiled["agent_ref"] = train_rec["agent_ref"] + if "uuid" in train_rec: + profiled["uuid"] = train_rec["uuid"] + if "_hash" in train_rec: + profiled["_hash"] = train_rec["_hash"] + if "_source" in train_rec: + profiled["_source"] = train_rec["_source"] + + profiled["pass_rate"] = pass_rate + profiled["pass_rate_passed"] = pass_rate_passed + profiled["pass_rate_total"] = pass_rate_total + + inp = np.array(stats["input"]) + outp = np.array(stats["output"]) + tot = np.array(stats["total"]) + + profiled["input_tokens/mean"] = float(np.mean(inp)) + profiled["input_tokens/std"] = float(np.std(inp)) + profiled["input_tokens/max"] = int(np.max(inp)) + profiled["input_tokens/min"] = int(np.min(inp)) + profiled["output_tokens/mean"] = float(np.mean(outp)) + profiled["output_tokens/std"] = float(np.std(outp)) + profiled["output_tokens/max"] = int(np.max(outp)) + profiled["output_tokens/min"] = int(np.min(outp)) + profiled["total_tokens/mean"] = float(np.mean(tot)) + profiled["total_tokens/std"] = float(np.std(tot)) + profiled["total_tokens/max"] = int(np.max(tot)) + profiled["total_tokens/min"] = int(np.min(tot)) + + out.write(json.dumps(profiled, ensure_ascii=False) + "\n") + written += 1 + + print(f"Done. Written: {written}, skipped (no tokens): {skipped_no_tokens}") + print(f"Output: {args.output_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_flow.py b/scripts/run_flow.py index 3a1808e..ffebb01 100755 --- a/scripts/run_flow.py +++ b/scripts/run_flow.py @@ -21,14 +21,20 @@ uv run python scripts/run_flow.py --help Examples: - # Run a single stage (short stage name from config) + # Run a single SFT stage uv run python scripts/run_flow.py sft --config nvflow/recipes/finance/workflows/training_sft.yaml - # Run a single stage from different workflow - uv run python scripts/run_flow.py generate_answers --config nvflow/recipes/finance/workflows/sdg_secque.yaml + # Run a single GRPO stage + uv run python scripts/run_flow.py collect_rollouts --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml + + # Run a GRPO stage for one environment + uv run python scripts/run_flow.py collect_rollouts -c nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e equivalence_llm_judge + + # Run a GRPO stage for multiple environments + uv run python scripts/run_flow.py training -c nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e mcqa equivalence_llm_judge # Run all stages in workflow - uv run python scripts/run_flow.py --all --config nvflow/recipes/finance/workflows/training_sft.yaml + uv run python scripts/run_flow.py --all --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml """ import argparse @@ -40,7 +46,7 @@ sys.path.insert(0, str(project_root)) # Auto-discover all recipes and stages -import nvflow.recipes.finance # noqa: F401, E402 +import nvflow.recipes # noqa: F401, E402 from nvflow.core import WorkflowRunner # noqa: E402 @@ -50,14 +56,20 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - # Run a single stage (short stage name from config) + # Run a single SFT stage uv run python scripts/run_flow.py sft --config nvflow/recipes/finance/workflows/training_sft.yaml - # Run a single stage from different workflow - uv run python scripts/run_flow.py generate_answers --config nvflow/recipes/finance/workflows/sdg_secque.yaml + # Run a single GRPO stage + uv run python scripts/run_flow.py collect_rollouts --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml + + # Run a GRPO stage for one environment + uv run python scripts/run_flow.py collect_rollouts -c nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e equivalence_llm_judge + + # Run a GRPO stage for multiple environments + uv run python scripts/run_flow.py training -c nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml -e mcqa equivalence_llm_judge # Run all stages - uv run python scripts/run_flow.py --all --config nvflow/recipes/finance/workflows/training_sft.yaml + uv run python scripts/run_flow.py --all --config nvflow/recipes/finance/workflows/grpo/qwen3_4b.yaml """, ) @@ -80,6 +92,14 @@ def main(): help="Run all stages defined in the workflow config", ) + parser.add_argument( + "--environment", + "-e", + nargs="+", + default=None, + help="Run for specific environment(s) only (default: all environments)", + ) + args = parser.parse_args() # Validate arguments @@ -96,10 +116,10 @@ def main(): # Run stages if args.all: print(f"Running all stages from {args.config}...") - runner.run() + runner.run(environment=args.environment) else: print(f"Running {len(args.stages)} stage(s) from {args.config}...") - runner.run(stages=args.stages) + runner.run(stages=args.stages, environment=args.environment) except FileNotFoundError as e: print(f"Error: {e}", file=sys.stderr) diff --git a/scripts/serve_vllm_patched.py b/scripts/serve_vllm_patched.py new file mode 100644 index 0000000..f97df77 --- /dev/null +++ b/scripts/serve_vllm_patched.py @@ -0,0 +1,300 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +# +"""Patched vLLM entrypoint for standalone ``vllm serve`` processes. + +Applies runtime workarounds **before** vLLM is imported, then builds and +runs the ``vllm.entrypoints.openai.api_server`` command directly. + +The upstream ``nemo_skills.inference.server.serve_vllm`` hardcodes +``tensor_parallel_size = num_gpus * num_nodes``, which assumes all GPUs +are for TP. This entrypoint replaces that with the formula:: + + tp = (num_gpus * num_nodes) // dp_size + +When ``--data-parallel-size`` is absent, ``dp_size`` defaults to 1 and the +formula gives the same result as the original code. This is model-agnostic +and backward-compatible. + +Tested with vLLM 0.17.1 and 0.18.1. +Other vLLM versions are safe -- patches skip gracefully if the expected +code snippets are not found, and vLLM starts normally unpatched. + +Active workarounds +------------------ +WORKAROUND(vllm-0.17-hermes) + Patches ``vllm/tool_parsers/hermes_tool_parser.py`` on disk so that + ``Hermes2ProToolParser.__init__`` caches tokenizer encode/decode results + behind a ``threading.Lock``. Fixes ``RuntimeError: Already borrowed`` + under concurrent chat-completion requests with tool calling enabled. + Remove when: vLLM ships fix from https://github.com/vllm-project/vllm/pull/35034 + +WORKAROUND(harmony-aarch64) + Pre-downloads tiktoken vocab files for gpt-oss models on aarch64. + The ``openai_harmony`` Rust binary cannot download them at runtime. + Remove when: openai_harmony ships a fixed aarch64 binary. + Tracking: https://github.com/openai/harmony/issues/71 +""" + +from __future__ import annotations + +import argparse +import os +import platform +import subprocess +import urllib.request +from importlib.util import find_spec +from pathlib import Path + +_TAG = "[serve_vllm_patched]" + +# --------------------------------------------------------------------------- +# WORKAROUND(vllm-0.17-hermes) -- hermes tool parser thread-safety +# +# Identical logic to RL/nemo_rl/models/generation/vllm/vllm_worker.py but +# applied on disk before vLLM is imported (standalone ``vllm serve`` has no +# in-process hook). +# +# Remove this entire section when vLLM ships the upstream fix. +# --------------------------------------------------------------------------- + +# --- Exact string snippets to locate and replace in hermes_tool_parser.py --- + +_OLD_IMPORT = "import json\nfrom collections.abc import Sequence" + +_NEW_IMPORT = "import json\nimport threading\nfrom collections.abc import Sequence" + +_OLD_CLASS_LINE = "class Hermes2ProToolParser(ToolParser):" + +_NEW_CLASS_LINE = ( + "class Hermes2ProToolParser(ToolParser):\n" + " _tokenizer_lock = threading.Lock()\n" + " _tokenizer_cache = {}" +) + +_OLD_INIT = ( + " self.tool_call_start_token_ids = self.model_tokenizer.encode(\n" + " self.tool_call_start_token, add_special_tokens=False\n" + " )\n" + " self.tool_call_end_token_ids = self.model_tokenizer.encode(\n" + " self.tool_call_end_token, add_special_tokens=False\n" + " )\n" + "\n" + " self.tool_call_start_token_array = [\n" + " self.model_tokenizer.decode([token_id])\n" + " for token_id in self.tool_call_start_token_ids\n" + " ]\n" + "\n" + " self.tool_call_end_token_array = [\n" + " self.model_tokenizer.decode([token_id])\n" + " for token_id in self.tool_call_end_token_ids\n" + " ]" +) + +_NEW_INIT = ( + " _tid = id(self.model_tokenizer)\n" + " if _tid in Hermes2ProToolParser._tokenizer_cache:\n" + " _cached = Hermes2ProToolParser._tokenizer_cache[_tid]\n" + " self.tool_call_start_token_ids = _cached['start_ids']\n" + " self.tool_call_end_token_ids = _cached['end_ids']\n" + " self.tool_call_start_token_array = _cached['start_array']\n" + " self.tool_call_end_token_array = _cached['end_array']\n" + " else:\n" + " with Hermes2ProToolParser._tokenizer_lock:\n" + " if _tid in Hermes2ProToolParser._tokenizer_cache:\n" + " _cached = Hermes2ProToolParser._tokenizer_cache[_tid]\n" + " self.tool_call_start_token_ids = _cached['start_ids']\n" + " self.tool_call_end_token_ids = _cached['end_ids']\n" + " self.tool_call_start_token_array = _cached['start_array']\n" + " self.tool_call_end_token_array = _cached['end_array']\n" + " else:\n" + " self.tool_call_start_token_ids = self.model_tokenizer.encode(\n" + " self.tool_call_start_token, add_special_tokens=False\n" + " )\n" + " self.tool_call_end_token_ids = self.model_tokenizer.encode(\n" + " self.tool_call_end_token, add_special_tokens=False\n" + " )\n" + " self.tool_call_start_token_array = [\n" + " self.model_tokenizer.decode([token_id])\n" + " for token_id in self.tool_call_start_token_ids\n" + " ]\n" + " self.tool_call_end_token_array = [\n" + " self.model_tokenizer.decode([token_id])\n" + " for token_id in self.tool_call_end_token_ids\n" + " ]\n" + " Hermes2ProToolParser._tokenizer_cache[_tid] = {\n" + " 'start_ids': self.tool_call_start_token_ids,\n" + " 'end_ids': self.tool_call_end_token_ids,\n" + " 'start_array': self.tool_call_start_token_array,\n" + " 'end_array': self.tool_call_end_token_array,\n" + " }" +) + + +def _patch_hermes_tool_parser() -> None: # WORKAROUND(vllm-0.17-hermes) + """Patch hermes_tool_parser.py on disk before vLLM imports it.""" + spec = find_spec("vllm") + if spec is None or not spec.submodule_search_locations: + print(f"{_TAG} vLLM not found -- skipping hermes patch.") + return + + base_dir = next(iter(spec.submodule_search_locations)) + target = os.path.join(base_dir, "tool_parsers", "hermes_tool_parser.py") + + if not os.path.exists(target): + print(f"{_TAG} {target} not found -- skipping hermes patch.") + return + + with open(target) as f: + content = f.read() + + if "_tokenizer_cache" in content: + print(f"{_TAG} Hermes patch already applied.") + return + + if _OLD_INIT not in content: + print(f"{_TAG} WARNING: Expected code snippet not found in {target}.") + print(f"{_TAG} The vLLM version may have changed -- skipping hermes patch.") + return + + content = content.replace(_OLD_IMPORT, _NEW_IMPORT, 1) + content = content.replace(_OLD_CLASS_LINE, _NEW_CLASS_LINE, 1) + content = content.replace(_OLD_INIT, _NEW_INIT, 1) + + with open(target, "w") as f: + f.write(content) + + print(f"{_TAG} Successfully patched {target} for thread-safety.") + + +# --------------------------------------------------------------------------- +# WORKAROUND(harmony-aarch64) -- tiktoken vocab download for gpt-oss on ARM +# +# Remove this entire section when openai_harmony ships a fixed aarch64 binary. +# --------------------------------------------------------------------------- + +_TIKTOKEN_FILES = { + "o200k_base.tiktoken": "https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken", + "cl100k_base.tiktoken": "https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken", +} + + +def _ensure_tiktoken_cache() -> None: # WORKAROUND(harmony-aarch64) + """Download tiktoken vocab files if on aarch64 and set env vars.""" + if platform.machine() not in ("aarch64", "arm64"): + return + + cache_dir = Path("/tmp/tiktoken-encodings") + cache_dir.mkdir(parents=True, exist_ok=True) + + for filename, url in _TIKTOKEN_FILES.items(): + dest = cache_dir / filename + if dest.exists() and dest.stat().st_size > 0: + continue + try: + print(f"{_TAG} Downloading {filename} for aarch64 workaround...") + urllib.request.urlretrieve(url, dest) + print(f"{_TAG} {dest.stat().st_size:,} bytes -> {dest}") + except Exception as e: + print(f"{_TAG} WARNING: Failed to download {filename}: {e}") + return + + os.environ["TIKTOKEN_ENCODINGS_BASE"] = str(cache_dir) + os.environ.setdefault("TIKTOKEN_RS_CACHE_DIR", str(cache_dir)) + print(f"{_TAG} TIKTOKEN_ENCODINGS_BASE={cache_dir}") + + +# --------------------------------------------------------------------------- +# TP / DP arithmetic +# --------------------------------------------------------------------------- + + +def _extract_int_flag(args: list[str], flag: str, default: int = 1) -> int: + """Read an integer flag value from *args* without removing it. + + Supports both ``--flag N`` and ``--flag=N`` forms. + """ + for i, a in enumerate(args): + if a == flag and i + 1 < len(args): + return int(args[i + 1]) + if a.startswith(f"{flag}="): + return int(a.split("=", 1)[1]) + return default + + +def _has_flag(args: list[str], flag: str) -> bool: + """Return True if *flag* is already present in *args*.""" + return any(a == flag or a.startswith(f"{flag}=") for a in args) + + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- + + +def main(): + os.environ["TOKENIZERS_PARALLELISM"] = "false" + + _patch_hermes_tool_parser() + _ensure_tiktoken_cache() + + parser = argparse.ArgumentParser( + description="Patched vLLM server entrypoint with DP-aware TP calculation", + ) + parser.add_argument("--model", required=True, help="Model path or HF name") + parser.add_argument("--num_gpus", type=int, required=True) + parser.add_argument("--num_nodes", type=int, default=1) + parser.add_argument("--port", type=int, default=5000, help="Server port") + parser.add_argument("--no_verbose", action="store_true", help="Suppress request logs") + args, unknown = parser.parse_known_args() + + dp_size = _extract_int_flag(unknown, "--data-parallel-size") + total_gpus = args.num_gpus * args.num_nodes + tp_size = total_gpus // dp_size + + # Multi-node DP: vLLM must know how many DP ranks fit on the local + # (master) node, otherwise it tries to place all ranks locally. + dp_size_local = args.num_gpus // tp_size + if dp_size > 1 and not _has_flag(unknown, "--data-parallel-size-local"): + unknown.extend(["--data-parallel-size-local", str(dp_size_local)]) + + print(f"{_TAG} Deploying model {args.model}") + print( + f"{_TAG} GPUs: {total_gpus} total" + f" (num_gpus={args.num_gpus} x num_nodes={args.num_nodes})" + f" -> TP={tp_size}, DP={dp_size}, DP_local={dp_size_local}" + ) + + cmd_list = [ + "python3", + "-m", + "vllm.entrypoints.openai.api_server", + f"--model={args.model}", + f"--served-model-name={args.model}", + "--trust-remote-code", + "--host=0.0.0.0", + f"--port={args.port}", + f"--tensor-parallel-size={tp_size}", + ] + if args.no_verbose: + cmd_list.extend(["--disable-log-requests", "--disable-log-stats"]) + cmd_list.extend(unknown) + + print(f"{_TAG} Starting OpenAI Server") + print(f"{_TAG} cmd: {' '.join(cmd_list)}") + subprocess.run(cmd_list, check=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/setup_containers.sh b/scripts/setup_containers.sh index 0f3ef77..71677c9 100755 --- a/scripts/setup_containers.sh +++ b/scripts/setup_containers.sh @@ -3,25 +3,20 @@ # setup_containers.sh # # Downloads and converts NeMo-Skills containers to .sqsh format in parallel using Slurm. -# Uses container definitions from cluster_configs/containers.yaml # # Usage: -# sbatch --account= scripts/setup_containers.sh [output_dir] [--platform PLATFORM] [--force] +# sbatch --account= scripts/setup_containers.sh --config FILE [output_dir] [--platform PLATFORM] [--force] # # Options: +# --config FILE Container definitions YAML file (required) # output_dir Directory to save .sqsh files (default: ./containers) # --platform PLATFORM Target platform: amd64 | arm64 (default: auto-detect from host) # --force Force re-download existing containers # # Examples: -# # Basic usage (auto-detects platform from host architecture) -# sbatch --account=llmservice_modelalignment_sft scripts/setup_containers.sh ./containers -# -# # Explicitly download ARM containers -# sbatch --account=llmservice_modelalignment_sft scripts/setup_containers.sh ./containers --platform arm64 -# -# # Force re-download all containers -# sbatch --account=llmservice_modelalignment_sft scripts/setup_containers.sh ./containers --force +# sbatch --account=llmservice_modelalignment_sft scripts/setup_containers.sh --config cluster_configs/my_containers.yaml ./containers +# sbatch --account=llmservice_modelalignment_sft scripts/setup_containers.sh --config cluster_configs/my_containers.yaml ./containers --platform arm64 +# sbatch --account=llmservice_modelalignment_sft scripts/setup_containers.sh --config cluster_configs/my_containers.yaml ./containers --force # # Platform support: # - Some containers are multi-arch (same tag for amd64/arm64) @@ -51,9 +46,6 @@ fi cd "$PROJECT_ROOT" || { echo "ERROR: Could not cd to $PROJECT_ROOT"; exit 1; } -YAML_FILE="cluster_configs/containers.yaml" -[[ -f "$YAML_FILE" ]] || { echo "ERROR: $YAML_FILE not found"; exit 1; } - # ============================================================================= # Output Helpers # ============================================================================= @@ -102,6 +94,7 @@ get_image_tag() { [[ "$tag" == "$1" ]] && echo "latest" || echo "$tag" } + # ============================================================================= # Dependency Check # ============================================================================= @@ -246,9 +239,13 @@ parse_arguments() { OUTPUT_DIR="./containers" PLATFORM="" FORCE=false + CONFIG_FILE="" while [[ $# -gt 0 ]]; do case "$1" in + --config) + [[ -z "$2" || "$2" =~ ^-- ]] && { print_error "--config requires a file path"; exit 1; } + CONFIG_FILE="$2"; shift 2 ;; --platform) [[ -z "$2" || "$2" =~ ^-- ]] && { print_error "--platform requires a value (amd64 or arm64)"; exit 1; } PLATFORM="$2"; shift 2 ;; @@ -261,7 +258,19 @@ parse_arguments() { esac done - # Auto-detect if not specified + # Require --config + if [[ -z "$CONFIG_FILE" ]]; then + print_error "--config is required." + echo "" + echo " Usage: sbatch --account= scripts/setup_containers.sh --config [output_dir]" + echo "" + echo " Create a config from the template if you haven't already:" + echo " cp cluster_configs/containers.yaml cluster_configs/my_containers.yaml" + echo " # Edit my_containers.yaml with your registry paths" + exit 1 + fi + + # Auto-detect platform if not specified if [[ -z "$PLATFORM" ]]; then PLATFORM=$(get_host_arch) if [[ -z "$PLATFORM" ]]; then @@ -269,7 +278,6 @@ parse_arguments() { exit 1 fi else - # Validate user-provided platform PLATFORM=$(validate_platform "$PLATFORM") if [[ -z "$PLATFORM" ]]; then print_error "Invalid platform. Supported: amd64, arm64" @@ -285,6 +293,10 @@ parse_arguments() { mkdir -p outputs/logs parse_arguments "$@" + +YAML_FILE="$CONFIG_FILE" +[[ -f "$YAML_FILE" ]] || { print_error "$YAML_FILE not found"; exit 1; } + check_dependencies # Warn if cross-platform download @@ -300,6 +312,7 @@ mkdir -p "$OUTPUT_DIR" print_header "Configuration" cat < + + # Dry run -- show what would be logged + uv run python scripts/wandb_consolidate.py \\ + --project finance-grpo \\ + --group grpo-training-finance_sec_search \\ + --dry-run + +Requirements: + pip install wandb +""" + +from __future__ import annotations + +import argparse +import os +import sys + +CONSOLIDATED_TAG = "wandb_consolidate" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Consolidate multiple WandB runs into a single dashboard." + ) + parser.add_argument( + "--project", + required=True, + help="WandB project name (e.g., finance-grpo).", + ) + parser.add_argument( + "--group", + required=True, + help="WandB group name to filter source runs (e.g., grpo-training-finance_sec_search).", + ) + parser.add_argument( + "--name", + default="consolidated", + help="WandB run name for the consolidated dashboard (default: consolidated).", + ) + parser.add_argument( + "--entity", + default=None, + help=( + "WandB entity (team/user). If not set, uses the default from " + "'wandb login'. Required if your default entity differs from " + "the project owner." + ), + ) + parser.add_argument( + "--append", + metavar="RUN_ID", + default=None, + help="Resume an existing consolidated WandB run by ID and append new data.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print summary without logging to WandB.", + ) + parser.add_argument( + "--skip-prefixes", + nargs="*", + default=["ray/"], + help="Skip metrics whose key starts with these prefixes (default: ray/).", + ) + parser.add_argument( + "--output-dir", + default=None, + help=( + "Directory for WandB local run data. Defaults to " + "{training-logs}/wandb_consolidated/ if not set. " + "Prevents WandB from writing to the source tree." + ), + ) + return parser.parse_args() + + +def fetch_source_runs( + api, project: str, entity: str | None, group: str, exclude_id: str | None = None +): + """Fetch source runs in the given group, excluding the consolidated run.""" + path = f"{entity}/{project}" if entity else project + runs = api.runs(path, filters={"group": group}) + if not runs: + print(f"No runs found in project={project}, group={group}") + sys.exit(1) + + run_list = [] + for r in runs: + if exclude_id and r.id == exclude_id: + continue + if r.tags and CONSOLIDATED_TAG in r.tags: + continue + run_list.append(r) + + run_list.sort(key=lambda r: r.summary.get("_step", 0)) + return run_list + + +def get_max_consolidated_step(api, project: str, entity: str | None, run_id: str) -> int: + """Get the highest step already logged in the consolidated run.""" + path = f"{entity}/{project}" if entity else project + try: + run = api.run(f"{path}/{run_id}") + max_step = run.summary.get("_step", -1) + return int(max_step) + except Exception: + return -1 + + +def collect_metrics(runs, skip_prefixes: list[str], min_step: int = -1): + """Collect scalar metrics from source runs, skipping already-consolidated steps.""" + all_rows: list[tuple[int, dict[str, float]]] = [] + + for run in runs: + print(f" Reading run: {run.name} ({run.id}), state={run.state}") + row_count = 0 + skipped = 0 + for row in run.scan_history(): + step = row.get("_step") + if step is None: + continue + if int(step) <= min_step: + skipped += 1 + continue + metrics = {} + for k, v in row.items(): + if k.startswith("_"): + continue + if any(k.startswith(p) for p in skip_prefixes): + continue + if isinstance(v, int | float): + metrics[k] = v + if metrics: + all_rows.append((int(step), metrics)) + row_count += 1 + msg = f" {row_count} steps collected" + if skipped: + msg += f" ({skipped} already consolidated, skipped)" + print(msg) + + all_rows.sort(key=lambda x: x[0]) + return all_rows + + +def print_summary(rows: list[tuple[int, dict]], runs, min_step: int): + """Print summary of data to consolidate.""" + if not rows: + print("No new data to consolidate.") + return + steps = [r[0] for r in rows] + all_keys: set[str] = set() + for _, metrics in rows: + all_keys.update(metrics.keys()) + print("\nConsolidation summary:") + print(f" Source runs: {len(runs)}") + if min_step >= 0: + print(f" Already consolidated up to step: {min_step}") + print(f" New steps: {len(steps)} (min={min(steps)}, max={max(steps)})") + print(f" Unique metrics: {len(all_keys)}") + print(f" Total data points: {sum(len(m) for _, m in rows)}") + + +def consolidate(args: argparse.Namespace): + """Main consolidation logic.""" + import wandb + + api = wandb.Api() + + min_step = -1 + exclude_id = None + + if args.append: + min_step = get_max_consolidated_step(api, args.project, args.entity, args.append) + exclude_id = args.append + print(f"Append mode: consolidated run {args.append}, max step = {min_step}") + + print(f"Fetching source runs from project={args.project}, group={args.group}") + runs = fetch_source_runs(api, args.project, args.entity, args.group, exclude_id=exclude_id) + print(f"Found {len(runs)} source runs\n") + + print("Collecting metrics...") + rows = collect_metrics(runs, args.skip_prefixes or [], min_step=min_step) + print_summary(rows, runs, min_step) + + if not rows: + print("Nothing to do.") + return + + if args.dry_run: + print("\n--dry-run: skipping WandB upload.") + return + + init_kwargs: dict = {"project": args.project} + if args.entity: + init_kwargs["entity"] = args.entity + + if args.append: + init_kwargs["id"] = args.append + init_kwargs["resume"] = "must" + print(f"\nAppending to existing WandB run: {args.append}") + else: + init_kwargs["name"] = args.name + init_kwargs["group"] = args.group + init_kwargs["tags"] = [CONSOLIDATED_TAG] + print(f"\nCreating new WandB run: {args.name}") + + wandb_dir = args.output_dir + if wandb_dir is None: + wandb_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "outputs", "wandb_consolidated" + ) + os.makedirs(wandb_dir, exist_ok=True) + init_kwargs["dir"] = wandb_dir + + run = wandb.init(**init_kwargs) + print(f"Run ID: {run.id}") + print(f"URL: {run.url}") + + logged = 0 + for step, metrics in rows: + run.log(metrics, step=step) + logged += len(metrics) + + run.finish() + print(f"\nDone. Logged {logged} data points across {len(rows)} steps.") + print(f"Run ID: {run.id} (use with --append for future updates)") + + +def main(): + args = parse_args() + consolidate(args) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index f68ee34..45657ee 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,14 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "urllib3", specifier = ">=2.6.3" }] +overrides = [ + { name = "cryptography", specifier = ">=47.0.0" }, + { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, + { name = "gitpython", specifier = ">=3.1.49" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "pygments", specifier = ">=2.20.0" }, + { name = "urllib3", specifier = ">=2.6.3" }, +] [[package]] name = "absl-py" @@ -48,7 +55,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -59,76 +66,76 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, + { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, + { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, + { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, ] [[package]] @@ -182,7 +189,7 @@ dependencies = [ { name = "anyio" }, { name = "distro" }, { name = "docstring-parser" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, @@ -199,6 +206,15 @@ version = "4.9.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } +[[package]] +name = "anyascii" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/ba/edebda727008390936da4a9bf677c19cd63b32d51e864656d2cbd1028e25/anyascii-0.3.3.tar.gz", hash = "sha256:c94e9dd9d47b3d9494eca305fef9447d00b4bf1a32aff85aa746fa3ec7fb95c3", size = 264680, upload-time = "2025-06-29T03:33:30.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/76/783b75a21ce3563b8709050de030ae253853b147bd52e141edc1025aa268/anyascii-0.3.3-py3-none-any.whl", hash = "sha256:f5ab5e53c8781a36b5a40e1296a0eeda2f48c649ef10c3921c1381b1d00dee7a", size = 345090, upload-time = "2025-06-29T03:33:28.356Z" }, +] + [[package]] name = "anyio" version = "4.9.0" @@ -620,14 +636,15 @@ wheels = [ [[package]] name = "compute-eval" version = "0.1.0" -source = { git = "https://github.com/NVIDIA/compute-eval.git?rev=2d14770#2d14770168dab9138dce23c37a7e0d89b801d96c" } +source = { git = "https://github.com/NVIDIA/compute-eval.git?rev=e01a5d2#e01a5d2284de906c601dec2c0d222ee3f4307cb9" } dependencies = [ { name = "anthropic" }, + { name = "docker" }, { name = "fire" }, { name = "h11" }, + { name = "kubernetes" }, { name = "numpy" }, { name = "openai" }, - { name = "psutil" }, { name = "pydantic" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -647,6 +664,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/56/6d6872f79d14c0cb02f1646cbb4592eef935857c0951a105874b7b62a0c3/contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f", size = 13277, upload-time = "2021-06-27T06:54:20.972Z" }, ] +[[package]] +name = "contractions" +version = "0.1.73" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "textsearch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/e4/725241b788963b460ce0118bfd5c505dd3d1bdd020ee740f9f39044ed4a7/contractions-0.1.73-py2.py3-none-any.whl", hash = "sha256:398cee3b69c37307a50dce4930d961a0f42b48fdae9562df73bed5683008d3bc", size = 8651, upload-time = "2022-11-15T14:05:54.573Z" }, +] + [[package]] name = "coverage" version = "7.13.2" @@ -723,36 +751,55 @@ wheels = [ [[package]] name = "cryptography" -version = "42.0.8" +version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/a7/1498799a2ea06148463a9a2c10ab2f6a921a74fb19e231b27dc412a748e2/cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2", size = 671250, upload-time = "2024-06-04T19:55:08.609Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/8b/1b929ba8139430e09e140e6939c2b29c18df1f2fc2149e41bdbdcdaf5d1f/cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e", size = 5899961, upload-time = "2024-06-04T19:53:57.933Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5d/31d833daa800e4fab33209843095df7adb4a78ea536929145534cbc15026/cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d", size = 3114353, upload-time = "2024-06-04T19:54:12.171Z" }, - { url = "https://files.pythonhosted.org/packages/5d/32/f6326c70a9f0f258a201d3b2632bca586ea24d214cec3cf36e374040e273/cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902", size = 3647773, upload-time = "2024-06-04T19:54:07.051Z" }, - { url = "https://files.pythonhosted.org/packages/35/66/2d87e9ca95c82c7ee5f2c09716fc4c4242c1ae6647b9bd27e55e920e9f10/cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801", size = 3839763, upload-time = "2024-06-04T19:54:30.383Z" }, - { url = "https://files.pythonhosted.org/packages/c2/de/8083fa2e68d403553a01a9323f4f8b9d7ffed09928ba25635c29fb28c1e7/cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949", size = 3632661, upload-time = "2024-06-04T19:54:32.955Z" }, - { url = "https://files.pythonhosted.org/packages/07/40/d6f6819c62e808ea74639c3c640f7edd636b86cce62cb14943996a15df92/cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9", size = 3851536, upload-time = "2024-06-04T19:53:53.131Z" }, - { url = "https://files.pythonhosted.org/packages/5c/46/de71d48abf2b6d3c808f4fbb0f4dc44a4e72786be23df0541aa2a3f6fd7e/cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583", size = 3754209, upload-time = "2024-06-04T19:54:55.259Z" }, - { url = "https://files.pythonhosted.org/packages/25/c9/86f04e150c5d5d5e4a731a2c1e0e43da84d901f388e3fea3d5de98d689a7/cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7", size = 3923551, upload-time = "2024-06-04T19:54:16.46Z" }, - { url = "https://files.pythonhosted.org/packages/53/c2/903014dafb7271fb148887d4355b2e90319cad6e810663be622b0c933fc9/cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b", size = 3739265, upload-time = "2024-06-04T19:54:23.194Z" }, - { url = "https://files.pythonhosted.org/packages/95/26/82d704d988a193cbdc69ac3b41c687c36eaed1642cce52530ad810c35645/cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7", size = 3937371, upload-time = "2024-06-04T19:55:04.303Z" }, - { url = "https://files.pythonhosted.org/packages/cf/71/4e0d05c9acd638a225f57fb6162aa3d03613c11b76893c23ea4675bb28c5/cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2", size = 2438849, upload-time = "2024-06-04T19:54:27.39Z" }, - { url = "https://files.pythonhosted.org/packages/06/0f/78da3cad74f2ba6c45321dc90394d70420ea846730dc042ef527f5a224b5/cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba", size = 2889090, upload-time = "2024-06-04T19:54:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/60/12/f064af29190cdb1d38fe07f3db6126091639e1dece7ec77c4ff037d49193/cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28", size = 5901232, upload-time = "2024-06-04T19:54:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/43/c2/4a3eef67e009a522711ebd8ac89424c3a7fe591ece7035d964419ad52a1d/cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e", size = 3648711, upload-time = "2024-06-04T19:54:44.323Z" }, - { url = "https://files.pythonhosted.org/packages/49/1c/9f6d13cc8041c05eebff1154e4e71bedd1db8e174fff999054435994187a/cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70", size = 3841968, upload-time = "2024-06-04T19:54:57.911Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f9/c3d4f19b82bdb25a3d857fe96e7e571c981810e47e3f299cc13ac429066a/cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c", size = 3633032, upload-time = "2024-06-04T19:54:48.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e2/b7e6e8c261536c489d9cf908769880d94bd5d9a187e166b0dc838d2e6a56/cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7", size = 3852478, upload-time = "2024-06-04T19:54:50.599Z" }, - { url = "https://files.pythonhosted.org/packages/a2/68/e16751f6b859bc120f53fddbf3ebada5c34f0e9689d8af32884d8b2e4b4c/cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e", size = 3754102, upload-time = "2024-06-04T19:54:46.231Z" }, - { url = "https://files.pythonhosted.org/packages/0f/38/85c74d0ac4c540780e072b1e6f148ecb718418c1062edcb20d22f3ec5bbb/cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961", size = 3925042, upload-time = "2024-06-04T19:54:34.767Z" }, - { url = "https://files.pythonhosted.org/packages/89/f4/a8b982e88eb5350407ebdbf4717b55043271d878705329e107f4783555f2/cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1", size = 3738833, upload-time = "2024-06-04T19:54:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2b/be327b580645927bb1a1f32d5a175b897a9b956bc085b095e15c40bac9ed/cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14", size = 3938751, upload-time = "2024-06-04T19:54:37.837Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d5/c6a78ffccdbe4516711ebaa9ed2c7eb6ac5dfa3dc920f2c7e920af2418b0/cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c", size = 2439281, upload-time = "2024-06-04T19:53:55.903Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7b/b0d330852dd5953daee6b15f742f15d9f18e9c0154eb4cfcc8718f0436da/cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a", size = 2886038, upload-time = "2024-06-04T19:54:18.707Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, ] [[package]] @@ -883,6 +930,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + [[package]] name = "editdistance" version = "0.8.1" @@ -902,6 +958,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/f0/65101e51dc7c850e7b7581a5d8fa8721a1d7479a0dca6c08386328e19882/editdistance-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:09f01ed51746d90178af7dd7ea4ebb41497ef19f53c7f327e864421743dffb0a", size = 79853, upload-time = "2024-02-10T07:44:05.687Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "evalplus" version = "0.3.0.dev27" @@ -1227,14 +1292,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.46" +version = "3.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] [[package]] @@ -1292,7 +1357,7 @@ dependencies = [ { name = "ffmpy" }, { name = "gradio-client" }, { name = "groovy" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "huggingface-hub" }, { name = "jinja2" }, { name = "markupsafe" }, @@ -1326,7 +1391,7 @@ version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fsspec" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "huggingface-hub" }, { name = "packaging" }, { name = "typing-extensions" }, @@ -1470,18 +1535,17 @@ wheels = [ [[package]] name = "httpx" -version = "0.27.2" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, - { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189, upload-time = "2024-08-27T12:54:01.334Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395, upload-time = "2024-08-27T12:53:59.653Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [package.optional-dependencies] @@ -1506,7 +1570,7 @@ dependencies = [ { name = "filelock" }, { name = "fsspec" }, { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "packaging" }, { name = "pyyaml" }, { name = "shellingham" }, @@ -1562,14 +1626,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.7.1" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, ] [[package]] @@ -1769,7 +1833,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.26.0" +version = "4.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1777,9 +1841,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, ] [[package]] @@ -1794,6 +1858,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "kubernetes" +version = "35.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642, upload-time = "2026-01-16T01:05:27.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602, upload-time = "2026-01-16T01:05:25.991Z" }, +] + [[package]] name = "langcodes" version = "3.5.1" @@ -1803,6 +1887,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/c1/d10b371bcba7abce05e2b33910e39c33cfa496a53f13640b7b8e10bb4d2b/langcodes-3.5.1-py3-none-any.whl", hash = "sha256:b6a9c25c603804e2d169165091d0cdb23934610524a21d226e4f463e8e958a72", size = 183050, upload-time = "2025-12-02T16:21:59.954Z" }, ] +[[package]] +name = "langdetect" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" } + [[package]] name = "language-data" version = "1.4.0" @@ -1978,13 +2071,13 @@ wheels = [ [[package]] name = "litellm" -version = "1.81.6" +version = "1.83.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, { name = "fastuuid" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "importlib-metadata" }, { name = "jinja2" }, { name = "jsonschema" }, @@ -1994,9 +2087,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/f3/194a2dca6cb3eddb89f4bc2920cf5e27542256af907c23be13c61fe7e021/litellm-1.81.6.tar.gz", hash = "sha256:f02b503dfb7d66d1c939f82e4db21aeec1d6e2ed1fe3f5cd02aaec3f792bc4ae", size = 13878107, upload-time = "2026-02-01T04:02:27.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/05/3516cc7386b220d388aa0bd833308c677e94eceb82b2756dd95e06f6a13f/litellm-1.81.6-py3-none-any.whl", hash = "sha256:573206ba194d49a1691370ba33f781671609ac77c35347f8a0411d852cf6341a", size = 12224343, upload-time = "2026-02-01T04:02:23.704Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" }, ] [package.optional-dependencies] @@ -2255,7 +2348,7 @@ version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "httpx-sse" }, { name = "jsonschema" }, { name = "pydantic" }, @@ -2523,11 +2616,10 @@ wheels = [ [[package]] name = "nemo-run" -version = "0.8.0rc0.dev0" -source = { git = "https://github.com/NVIDIA-NeMo/Run#4a9db7770fb682c7e4f97cedceb944127b0bee12" } +version = "0.9.0rc0.dev0" +source = { git = "https://github.com/NVIDIA-NeMo/Run#17ae86b64d7f75653351664f5d8c9e466faede00" } dependencies = [ { name = "catalogue" }, - { name = "cryptography" }, { name = "fabric" }, { name = "fiddle" }, { name = "inquirerpy" }, @@ -2545,11 +2637,12 @@ dependencies = [ [[package]] name = "nemo-skills" version = "0.7.0" -source = { git = "https://github.com/NVIDIA/NeMo-Skills.git?rev=7d6c49a51efb441b61db3e78f6ffa2f04c9a68ef#7d6c49a51efb441b61db3e78f6ffa2f04c9a68ef" } +source = { git = "https://github.com/NVIDIA/NeMo-Skills.git?rev=022904023ad7a83a87662a313cf72e7df5891d55#022904023ad7a83a87662a313cf72e7df5891d55" } dependencies = [ { name = "bs4" }, { name = "click" }, { name = "compute-eval" }, + { name = "contractions" }, { name = "datasets" }, { name = "editdistance" }, { name = "evalplus" }, @@ -2558,12 +2651,13 @@ dependencies = [ { name = "flask" }, { name = "func-timeout" }, { name = "gradio" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "huggingface-hub" }, { name = "hydra-core" }, { name = "ipython" }, { name = "iso639-lang" }, { name = "langcodes" }, + { name = "langdetect" }, { name = "language-data" }, { name = "litellm", extra = ["caching"] }, { name = "math-verify", extra = ["antlr4-9-3"] }, @@ -2572,14 +2666,20 @@ dependencies = [ { name = "nemo-run" }, { name = "numpy" }, { name = "openai" }, + { name = "openpyxl" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pyxlsb" }, { name = "pyyaml" }, { name = "rank-bm25" }, { name = "requests" }, + { name = "rich" }, { name = "sacrebleu" }, { name = "scikit-learn" }, { name = "sentence-transformers" }, { name = "serpapi" }, { name = "sympy" }, + { name = "torchcodec" }, { name = "tqdm" }, { name = "transformers" }, { name = "typer" }, @@ -2646,11 +2746,9 @@ wheels = [ name = "nvflow" source = { editable = "." } dependencies = [ - { name = "gradio" }, { name = "jsonlines" }, { name = "nemo-skills" }, { name = "omegaconf" }, - { name = "pillow" }, { name = "rich" }, { name = "typer" }, ] @@ -2674,14 +2772,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "gradio", specifier = ">=6.9.0" }, { name = "jsonlines", specifier = ">=4.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.7.0" }, - { name = "nemo-skills", git = "https://github.com/NVIDIA/NeMo-Skills.git?rev=7d6c49a51efb441b61db3e78f6ffa2f04c9a68ef" }, + { name = "nemo-skills", git = "https://github.com/NVIDIA/NeMo-Skills.git?rev=022904023ad7a83a87662a313cf72e7df5891d55" }, { name = "omegaconf", specifier = ">=2.3.0" }, - { name = "pillow", specifier = ">=12.1.1" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.2.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.3.0" }, @@ -2829,6 +2925,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "omegaconf" version = "2.3.0" @@ -2844,21 +2949,21 @@ wheels = [ [[package]] name = "openai" -version = "2.16.0" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] [[package]] @@ -2884,6 +2989,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/68/162c97ea78c957d68ecf78a5c5041d2e25bd5562bdf5d89a6cbf7f8429bf/opencensus_context-0.1.3-py2.py3-none-any.whl", hash = "sha256:073bb0590007af276853009fac7e4bab1d523c3f03baf4cb4511ca38967c6039", size = 5060, upload-time = "2022-08-03T22:20:20.352Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.39.1" @@ -3183,71 +3300,71 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] @@ -3441,34 +3558,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] -[[package]] -name = "psutil" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, -] - [[package]] name = "ptyprocess" version = "0.7.0" @@ -3519,6 +3608,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/da/fcc9a9fcd4ca946ff402cff20348e838b051d69f50f5d1f5dca4cd3c5eb8/py_spy-0.4.1-py2.py3-none-win_amd64.whl", hash = "sha256:d92e522bd40e9bf7d87c204033ce5bb5c828fca45fa28d970f58d71128069fdc", size = 1818784, upload-time = "2025-07-31T19:33:23.802Z" }, ] +[[package]] +name = "pyahocorasick" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/3c/dc9e31a0f004eabe2ef5d31456766555a02e2af29e159daa31266934af79/pyahocorasick-2.3.1.tar.gz", hash = "sha256:9d0f6bb522237ed7f111ed59c9e8baea7d1e75813587b6773babd43bda35db9f", size = 105024, upload-time = "2026-04-27T16:30:25.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/a6/2ee9301a36c9d6bcd7e745e8a98e72fddf1ff1cd3ae899f498383c3ad1c9/pyahocorasick-2.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f0df14cb10ed1e942a30c0f11d242472452e7c567acbf3ac070e5d6912b71ca9", size = 60112, upload-time = "2026-04-27T16:31:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c6/f242c7966d8207822d7ecb183101522ca03df5f302ee6520fe4412f03fae/pyahocorasick-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:873911f1d80acd82ac00aae277a9a2b335a0c0cac0a0ef1c6635b57badc6f7a6", size = 34154, upload-time = "2026-04-27T16:31:39.719Z" }, + { url = "https://files.pythonhosted.org/packages/f7/01/0a7387a6327f4ef9b7dcf3cea84dfea3e4b0e85eb37a52b612985b1f9a9a/pyahocorasick-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9a4d4f5b05ce9d8af82c40ed39cd6892613e9e8bf1b5e6ea79009c566430adb1", size = 113543, upload-time = "2026-04-27T16:31:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f2/d13807476195e4ec5999a78f22db592a64da54229c9183438f3165105779/pyahocorasick-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ec1d3465f25a5063c7eaa85ecb106cbe256064669c754e0b13b2483cf613a98", size = 114873, upload-time = "2026-04-27T16:31:42.625Z" }, + { url = "https://files.pythonhosted.org/packages/af/32/d79302845be8629f9aee2a3dbeb9ad089b036f089e99589a08814e7e5910/pyahocorasick-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4e1e90eb2e755c79b9b904fd8adcca61c22b4b48811b9435f0c4b2d718895d6", size = 116455, upload-time = "2026-04-27T16:31:44.366Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/2e3019eb9f4404dc1fe1309535d1220740cc95275ad1b4a70f7f891cb296/pyahocorasick-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3922f66721b5b777eae758d2a0acffd98ee97dc7e6e452ba533d1c5892e15b7", size = 117863, upload-time = "2026-04-27T16:31:45.831Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6e/5fa2f6fafb7a5bb82cad6e2ef3c8eed7c859ba16242766a5a425e19334b5/pyahocorasick-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:f5cc3c021be241fe9317c5991f8efba2b876e3956691322ad9e55c0d9ff7c599", size = 35258, upload-time = "2026-04-27T16:31:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/31/16/4ea7db7a118778a2f56b217b8f142d1bd55e10cb6c6d59329bc58c41952a/pyahocorasick-2.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1b16eab55f961671c6eff5ead4e3fda6e85982acea86fda734b68e39e52dcd3b", size = 60118, upload-time = "2026-04-27T16:31:48.173Z" }, + { url = "https://files.pythonhosted.org/packages/ec/53/08c717e8696b3f243be89278155512a360a13b5a11bfe87a3a417f180c5e/pyahocorasick-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec6908893dffc271c1f89fe5a0f6ae872c5b7fdfb82ce032185a1fcf02339a60", size = 34160, upload-time = "2026-04-27T16:31:49.287Z" }, + { url = "https://files.pythonhosted.org/packages/5c/11/4464450c9c44719ab47082eda69424de22af51ef68c482f7e8c48a30a727/pyahocorasick-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43e79e7f1737e8bd5290ee61bfbbc0af0a44975b8aa719ffbb00e3cd8c5c8e35", size = 113498, upload-time = "2026-04-27T16:31:50.925Z" }, + { url = "https://files.pythonhosted.org/packages/64/e0/398f558e004616411ae6914666f0aa51eb019405ef4f48358e6a9b26bc4d/pyahocorasick-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:343c93387146ddef771118cab8fc60e3be1c9c5595b647ad6c898fc940a63e20", size = 114814, upload-time = "2026-04-27T16:31:52.329Z" }, + { url = "https://files.pythonhosted.org/packages/84/dc/a7c78f3fafdee825ab2a69c7aeedc8c3bf1a82f69a710071bbeac3d8be29/pyahocorasick-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:648ee2e1dae6753cbe153d610cd8208f3da00e20456d3696de49a7606106afad", size = 116447, upload-time = "2026-04-27T16:31:54.196Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f028911b158fd9d6ea0c50a99b17b798f4cbb4d14aedf9bc07dcebfd406c/pyahocorasick-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b52bb618a6d29223470c5518daa59f319cbbca878373dcec3ca89a63759c0e5", size = 117863, upload-time = "2026-04-27T16:31:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/30/75/5d5d377fab5b93462ff22496ac5a09725534ec37217626b0a5480c321e5a/pyahocorasick-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:31c743e80e92f81c390214b69f474945689f0f83db8d9bae7118a4623e5da63d", size = 35244, upload-time = "2026-04-27T16:31:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/00/0b/ce8637d57f122533067e5080cbd54d4698968acd2a16921469c838ee1ae3/pyahocorasick-2.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9b87fa566bd71b46407ea8cfd86ddc6c97ba7f20eb29041ce9b5213b111e76be", size = 60047, upload-time = "2026-04-27T16:31:58.019Z" }, + { url = "https://files.pythonhosted.org/packages/63/8d/f98d8caad8bed8dc70b5b406704ca652c5bb59168984424e61732f31de50/pyahocorasick-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:523c5460afae4b9228bb9df7571ef23b90ceb3411428beb7df167d696ae054dc", size = 34114, upload-time = "2026-04-27T16:31:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/60/97/b06f783364347a369c86344dbebb194535b7f41bf1df0f42dc4e64e3b655/pyahocorasick-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0e59226baf6ffb5acb6f72868ef345a4bd23d2a30ef08a9e1bf51043ea9b430d", size = 113504, upload-time = "2026-04-27T16:32:00.735Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/54b057c13eae27ceca51e68e13e1194e4c624d624b0369b571177f390a62/pyahocorasick-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c90328fb64f6d1c24bbf969194f4fe0b3aacbdddadf28ec920b34a524681a54", size = 114564, upload-time = "2026-04-27T16:32:02.184Z" }, + { url = "https://files.pythonhosted.org/packages/79/c1/a0c0ed44ebe2a0e62bebc545158707b9543fa685c384a9af90bb568444cf/pyahocorasick-2.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b10d29fb3eddf8228e41d285f2e052efddb99b6dd1ed1e0f28f00d0d0570005", size = 116371, upload-time = "2026-04-27T16:32:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/c4/db/d174d6bbc6caa811ac3c3695de28785b36d83ee94aecd461f58e621068fc/pyahocorasick-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba7b98de0ff3203e2cd8c27682f6934c0d893cd97e65a45b8478e468d9919c90", size = 117877, upload-time = "2026-04-27T16:32:05.407Z" }, + { url = "https://files.pythonhosted.org/packages/c5/96/37c50ac951bb0260ec38d8d12e5b51587ef1ef4035c279088f2771544b28/pyahocorasick-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4acb11a0a2ff10519465749d22ad70789e9fe7f81dc8fe9957a8868e499e18ab", size = 35987, upload-time = "2026-04-27T16:32:07.08Z" }, +] + [[package]] name = "pyarrow" version = "23.0.0" @@ -3703,11 +3821,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -3774,7 +3892,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -3783,9 +3901,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -3841,11 +3959,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -3882,6 +4000,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pyxlsb" +version = "1.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/13/eebaeb7a40b062d1c6f7f91d09e73d30a69e33e4baa7cbe4b7658548b1cd/pyxlsb-1.0.10.tar.gz", hash = "sha256:8062d1ea8626d3f1980e8b1cfe91a4483747449242ecb61013bc2df85435f685", size = 22424, upload-time = "2022-10-14T19:17:47.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/92/345823838ae367c59b63e03aef9c331f485370f9df6d049256a61a28f06d/pyxlsb-1.0.10-py2.py3-none-any.whl", hash = "sha256:87c122a9a622e35ca5e741d2e541201d28af00fb46bec492cfa9586890b120b4", size = 23849, upload-time = "2022-10-14T19:17:46.079Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -4123,6 +4250,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "rich" version = "14.3.2" @@ -4277,7 +4417,7 @@ name = "safehttpx" version = "0.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/d1/4282284d9cf1ee873607a46442da977fc3c985059315ab23610be31d5885/safehttpx-0.1.7.tar.gz", hash = "sha256:db201c0978c41eddb8bb480f3eee59dd67304fdd91646035e9d9a720049a9d23", size = 10385, upload-time = "2025-10-24T18:30:09.783Z" } wheels = [ @@ -4628,6 +4768,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] +[[package]] +name = "textsearch" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyascii" }, + { name = "pyahocorasick" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/7c/18ab4807196aac89a114a98732462436cbd48db16873dc4669087f354bc7/textsearch-0.0.24.tar.gz", hash = "sha256:2d23b5c3116715b65bccc18bc870ecc236ec8480d48cd5f257cc60bf66bb241a", size = 49755, upload-time = "2022-09-02T14:04:42.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/0f/6f08dd89e9d71380a369b1f5b6c97a32d62fc9cfacc1c5b8329505b9e495/textsearch-0.0.24-py2.py3-none-any.whl", hash = "sha256:1bbc4cc36300fbf0bbaa865500f84e907c85f6a48faf37da6e098407b405ed09", size = 7606, upload-time = "2022-09-02T14:04:40.105Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -4761,6 +4914,11 @@ dependencies = [ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" }, { url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" }, { url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" }, { url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" }, @@ -4783,6 +4941,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" }, ] +[[package]] +name = "torchcodec" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/f2/85da3abfef5443b0fd7a70706dabf54e0fb5592ed6b03b3f8bfccff06af0/torchcodec-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3e5abd61ad9de69a7008545f5c08736b66298f4e895b1f9fad01ae41bce75252", size = 4368773, upload-time = "2026-03-24T15:56:22.952Z" }, + { url = "https://files.pythonhosted.org/packages/73/4c/b466aa762abf2e771cfd865bac1c03259a66c482b83978898c00810cb97f/torchcodec-0.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:aca62724bf7d4b5b70db60183e8bee67ba77f4f0afdb052e6d1900528b97de6e", size = 2397523, upload-time = "2026-03-24T15:56:25.026Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/2488f553e8014c911652c2bf29265b73ec3753ac13ac4816b9c831ad27e2/torchcodec-0.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:669895505b7f0cd17bd6a71cf8bfa85190a9de1dc87f77051682c73149d58023", size = 2545573, upload-time = "2026-03-24T15:56:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/21/a7/e12e7cc5d69dd55e3edef7f16f46bcca5978c6262c86e51ccd3913bff92b/torchcodec-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a1f023e26dddca77c1c81de83cf9201ecb363b818405557da70e4777b966697", size = 1921067, upload-time = "2026-03-24T15:56:28.417Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/84c597fb17481eec5885d02cc3f95ba047f7e0411009f7c0e8087c3a52a2/torchcodec-0.11.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:9308e1e9c22a14f8e3b35a16ae8167eadc5939dd7d699b1cb6e38f57bfcc6563", size = 4289581, upload-time = "2026-03-24T15:56:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/57/97f90c0e2abc8253412281bb1185375dc9b9aa9bd519d14ceeb52ecbf6ec/torchcodec-0.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:0120f91479e2845ed481ce84ad5248f70d71009fb2f9b5ea182260c5c901c804", size = 2399066, upload-time = "2026-03-24T15:56:32.016Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c5/e078ef510cd4ad15bb185c184b0f0fbb68c8ffe8afeed24c3d18e7acaadb/torchcodec-0.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0e0fd9a9045271f53d78e2d1153ac88d1369808fa0df8db135941d3464080b76", size = 2548454, upload-time = "2026-03-24T15:56:33.832Z" }, + { url = "https://files.pythonhosted.org/packages/00/9f/f1ecdeb6e53e9ab5d78e6cb191bd1191683e3f0c02950022cf02c939c5fc/torchcodec-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:bda3a330a5578ccc753317f388b492ac19296d9e0e0bb50a43faff512981f995", size = 1920982, upload-time = "2026-03-24T15:56:37.022Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a8/2bb67ce8f849c52efdbb45a14dfed7ad378004df3b584a033b47c771b618/torchcodec-0.11.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:386dffbd76ae7fcf77f463755830a0489bfecaca55b354a4e794c06acc74540e", size = 4408063, upload-time = "2026-03-24T15:56:38.875Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/3aad20b6b913b6800192114c9e89d342783cfba2cd234cf5032cd175d474/torchcodec-0.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ba874466cb5eb7be30062ce1a580d795af42b609587929af631e068693b55233", size = 2402237, upload-time = "2026-03-24T15:56:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/79/e7/11ed146f043658bbc0f35cc9ef9064a55401a10d41ef1b2714e6bd2763c6/torchcodec-0.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:71b4991dd45759dd1836dd0b11ce8b509a6b74487ec47f2b0e679f87e2927cba", size = 2549919, upload-time = "2026-03-24T15:56:42.197Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/e5fe67d84ee1ce2bf38c10b1424d87b3719b5279afbca3316fbd439010c5/torchcodec-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:9269c559e21d678401febe3fae59e3ea585896e9bf7993fb859bebb4468b6a1d", size = 1925004, upload-time = "2026-03-24T15:56:44.411Z" }, +] + [[package]] name = "torchx" version = "0.7.0" @@ -4880,6 +5057,14 @@ version = "0.23.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/85/a61c782afbb706a47d990eaee6977e7c2bd013771c5bf5c81c617684f286/tree_sitter_c_sharp-0.23.1.tar.gz", hash = "sha256:322e2cfd3a547a840375276b2aea3335fa6458aeac082f6c60fec3f745c967eb", size = 1317728, upload-time = "2024-11-11T05:25:32.535Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/dc/d4a0ad9e466263728f80f9dac399609473af01c1aba2ea3ea8879ce56276/tree_sitter_c_sharp-0.23.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e87be7572991552606a3155d2f6c2045ded8bce94bfd9f74bf521d949c219a1c", size = 333661, upload-time = "2026-04-14T15:11:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/61/7a/5c862770460a2e27079e725585ad2718100373c09448c14e36934ef44414/tree_sitter_c_sharp-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:86c2fdf178c66474a1be2965602818d30780e4e3ed890e3c206931f65d9a154c", size = 376295, upload-time = "2026-04-14T15:11:15.346Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/0571a3a34c0feda60a9c37cf6dd5edfdbc24f8fcb1e48b6b6eb0f324ad2a/tree_sitter_c_sharp-0.23.1-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:035d259e64c41d02cc45afc3b8b46388b232e7d16d84734d851cca7334761da5", size = 358331, upload-time = "2026-04-14T15:11:16.418Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/0f7e1f50f6365338eb700f01710da0adc49a49fa9a8443e5a90ea4f29491/tree_sitter_c_sharp-0.23.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa472cb9de7e14fee9408e144f29f68384cd8e9c677dff0002da19f361a59bdf", size = 359444, upload-time = "2026-04-14T15:11:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/98/60/129bd56d5ef22b4ae254940a09b6d3ed873093218868a3f9635d571d514e/tree_sitter_c_sharp-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1a0ea86eccff74e85ab4a2cf77c813fad7c84162962ce242dff0c51601028832", size = 358143, upload-time = "2026-04-14T15:11:18.755Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cd/e12cdca47e0c56151cb4b156d48091b7bc1d968e072c1656cf6b73fe7218/tree_sitter_c_sharp-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8ab26dc998bbd4b4287b129f67c10ca715deb402ed77d0645674490ea509097e", size = 357524, upload-time = "2026-04-14T15:11:19.717Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2c/f742d60f818cba83760f4975c7158d1c96c36b5807e95a843db7fb8c64b7/tree_sitter_c_sharp-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:d4486653feaff3314ef45534dcb6f9ea8ab3aa160896287c6473788f88eb38be", size = 338755, upload-time = "2026-04-14T15:11:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e4/8a8642b9bba86248ac2facc81ffb187c06c6768efa56c79d61fab70d736b/tree_sitter_c_sharp-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:e7a14b76ec23cc8386cf662d5ea602d81331376c93ca6299a97b174047790345", size = 337261, upload-time = "2026-04-14T15:11:22.111Z" }, { url = "https://files.pythonhosted.org/packages/58/04/f6c2df4c53a588ccd88d50851155945cff8cd887bd70c175e00aaade7edf/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2b612a6e5bd17bb7fa2aab4bb6fc1fba45c94f09cb034ab332e45603b86e32fd", size = 372235, upload-time = "2024-11-11T05:25:19.424Z" }, { url = "https://files.pythonhosted.org/packages/99/10/1aa9486f1e28fc22810fa92cbdc54e1051e7f5536a5e5b5e9695f609b31e/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a8b98f62bc53efcd4d971151950c9b9cd5cbe3bacdb0cd69fdccac63350d83e", size = 419046, upload-time = "2024-11-11T05:25:20.679Z" }, { url = "https://files.pythonhosted.org/packages/0f/21/13df29f8fcb9ba9f209b7b413a4764b673dfd58989a0dd67e9c7e19e9c2e/tree_sitter_c_sharp-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:986e93d845a438ec3c4416401aa98e6a6f6631d644bbbc2e43fcb915c51d255d", size = 415999, upload-time = "2024-11-11T05:25:22.359Z" }, @@ -5127,6 +5312,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/c1/d73f12f8cdb1891334a2ccf7389eed244d3941e74d80dd220badb937f3fb/wcwidth-0.5.3-py3-none-any.whl", hash = "sha256:d584eff31cd4753e1e5ff6c12e1edfdb324c995713f75d26c29807bb84bf649e", size = 92981, upload-time = "2026-01-31T03:52:09.14Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "werkzeug" version = "3.1.5"