From f9bfd453a58199563e961f10b42e748422b88cdf Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:01:29 -0700 Subject: [PATCH] Add typed verl v0.8 OPD config compiler --- CHANGELOG.md | 10 + PROJECT_STATE.md | 13 + examples/verl-opd-v0.8-single-gpu.yaml | 90 +++ src/miniverl/bridge/__init__.py | 9 +- src/miniverl/bridge/opd_v08.py | 845 +++++++++++++++++++++++++ src/miniverl/cli.py | 60 ++ tests/cli/test_cli.py | 25 + tests/unit/test_verl_opd_v08_config.py | 253 ++++++++ 8 files changed, 1304 insertions(+), 1 deletion(-) create mode 100644 examples/verl-opd-v0.8-single-gpu.yaml create mode 100644 src/miniverl/bridge/opd_v08.py create mode 100644 tests/unit/test_verl_opd_v08_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fb76c..27e1080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to miniVERL are recorded here. The format follows ## [Unreleased] +### verl v0.8 OPD config compiler + +- Added the typed `verl-opd-v0.8-single-gpu-v1` configuration profile and an + offline `bridge compile-opd` command. Resolved YAML and repeatable dotted + overrides compile into deterministic field-by-field compatibility reports. +- Unsupported policy-gradient OPD, task-reward mixtures, KL penalties, + multi-generation, multi-teacher and distributed dimensions fail closed. + Engine/resource fields are labelled as local reinterpretations rather than + upstream-exact behavior; the config-only command loads no model weights. + ## [0.7.1] - 2026-08-11 ### Product correction diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 81c3bb7..c0749b0 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -10,6 +10,19 @@ Canonical release state: stable `v0.7.1` (`830a4ca5d873bce4cdcc7c43a44d827b096e8 Every public version claim is generated from `release-state.yaml` and gated by `python scripts/release_state.py --check`. +## v0.8.0 single-GPU verl OPD pivot — PR A + +Development starts with the config-only profile +`verl-opd-v0.8-single-gpu-v1`, pinned to verl `v0.8.0` at +`7aed6b230776f963fa09509c10d9c3a767d1102c`. Typed models compile resolved YAML +and repeatable dotted overrides into a deterministic, checksummed field report. +The report distinguishes exact, semantically conformant, locally reinterpreted, +derived, informational-only and unsupported fields. PG OPD, task-reward +mixtures, KL penalties, multiple generations, multiple teachers and +distributed dimensions fail closed. `bridge compile-opd` is deliberately an +offline config compiler: it loads no weights and does not claim the prompt +runtime or verl engine equivalence implemented by later PRs. + ## v0.7.1 Product correction — RELEASE CANDIDATE Branch `v0.7.1-product-correction` starts from synchronized main diff --git a/examples/verl-opd-v0.8-single-gpu.yaml b/examples/verl-opd-v0.8-single-gpu.yaml new file mode 100644 index 0000000..43371de --- /dev/null +++ b/examples/verl-opd-v0.8-single-gpu.yaml @@ -0,0 +1,90 @@ +# Resolved fixture for the pinned verl-opd-v0.8-single-gpu-v1 compiler. +# This file compiles offline. Runtime execution is added in later v0.8 PRs. +data: + train_files: [data/train.parquet] + val_files: [data/val.parquet] + prompt_key: prompt + train_batch_size: 8 + max_prompt_length: 256 + max_response_length: 64 + filter_overlong_prompts: false + truncation: error + shuffle: true + seed: 17 +actor_rollout_ref: + model: + path: Qwen/Qwen3-0.6B + enable_gradient_checkpointing: true + lora_rank: 16 + lora_alpha: 32 + target_modules: [q_proj, v_proj] + lora_adapter_path: null + actor: + optim: + lr: 1e-5 + weight_decay: 0.01 + lr_warmup_steps: 0 + loss_agg_mode: token-mean + use_kl_loss: false + ppo_mini_batch_size: 8 + ppo_max_token_len_per_gpu: 2048 + use_dynamic_bsz: true + rollout: + name: vllm + n: 1 + temperature: 1.0 + top_p: 0.95 + tensor_model_parallel_size: 1 + gpu_memory_utilization: 0.5 + max_model_len: 320 + max_num_batched_tokens: 2048 + max_num_seqs: 8 +algorithm: + use_kl_in_reward: false +distillation: + enabled: true + teacher_key: data_source + n_gpus_per_node: 1 + nnodes: 1 + teacher_models: + teacher_model: + model_path: Qwen/Qwen3-1.7B + num_replicas: 1 + inference: + name: vllm + dtype: bfloat16 + tensor_model_parallel_size: 1 + data_parallel_size: 1 + pipeline_model_parallel_size: 1 + gpu_memory_utilization: 0.5 + max_model_len: 321 + distillation_loss: + loss_mode: forward_kl_topk + topk: 32 + use_task_rewards: false + distillation_loss_coef: 1.0 + loss_max_clamp: null + log_prob_min_clamp: -10.0 + use_policy_gradient: false +trainer: + project_name: mini-verl + experiment_name: opd-smoke + save_freq: 10 + test_freq: 10 + total_epochs: 1 + total_training_steps: 2 + n_gpus_per_node: 1 + nnodes: 1 +miniverl: + runtime: + mode: auto + memory: + vram_limit_gib: 16 + headroom_gib: 1.5 + batching: + rollout_batch_size: 2 + teacher_score_batch_size: 2 + update_trajectory_batch_size: 2 + teacher_adapter: + path: null + revision: null diff --git a/src/miniverl/bridge/__init__.py b/src/miniverl/bridge/__init__.py index b0e675b..f725268 100644 --- a/src/miniverl/bridge/__init__.py +++ b/src/miniverl/bridge/__init__.py @@ -10,5 +10,12 @@ VERL_REPOSITORY, VERL_TAG, ) +from miniverl.bridge.opd_v08 import VERL_OPD_V08_PROFILE -__all__ = ["BRIDGE_PROFILE", "VERL_COMMIT", "VERL_REPOSITORY", "VERL_TAG"] +__all__ = [ + "BRIDGE_PROFILE", + "VERL_COMMIT", + "VERL_OPD_V08_PROFILE", + "VERL_REPOSITORY", + "VERL_TAG", +] diff --git a/src/miniverl/bridge/opd_v08.py b/src/miniverl/bridge/opd_v08.py new file mode 100644 index 0000000..d1f333c --- /dev/null +++ b/src/miniverl/bridge/opd_v08.py @@ -0,0 +1,845 @@ +"""Typed compiler for miniVERL's pinned, single-GPU verl v0.8 OPD subset. + +This module compiles configuration only. It does not import verl, allocate model +weights, execute an inference engine, or claim distributed compatibility. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator + +from miniverl.bridge.contract import VERL_COMMIT, VERL_REPOSITORY, VERL_TAG +from miniverl.bridge.interpolation import reject_interpolation +from miniverl.errors import ConfigError + +__all__ = [ + "CompiledLocalExecutionPlan", + "VERL_OPD_V08_PROFILE", + "VerlOPDV08Profile", + "compile_verl_opd_v08", + "load_verl_opd_v08", + "parse_overrides", +] + +VERL_OPD_V08_PROFILE = "verl-opd-v0.8-single-gpu-v1" + +FieldClassification = Literal[ + "exact", + "semantically_conformant", + "locally_reinterpreted", + "derived", + "informational_only", + "unsupported", +] +RuntimeMode = Literal["dual_model_resident", "shared_backbone", "swap", "auto"] + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", validate_default=True) + + +def _finite(value: float, field: str) -> float: + if not math.isfinite(value): + raise ValueError(f"{field} must be finite") + return value + + +class VerlOPDDataConfig(_StrictModel): + train_files: list[str] + val_files: list[str] = Field(default_factory=list) + prompt_key: str = "prompt" + train_batch_size: int = Field(ge=1) + max_prompt_length: int = Field(ge=1) + max_response_length: int = Field(ge=1) + filter_overlong_prompts: bool = False + truncation: Literal["error", "left", "right"] = "error" + shuffle: bool = True + seed: int | None = None + + @field_validator("train_files", "val_files", mode="before") + @classmethod + def _normalize_paths(cls, value: Any) -> Any: + if isinstance(value, str): + return [value] + return value + + @field_validator("train_files", "val_files") + @classmethod + def _nonempty_paths(cls, value: list[str]) -> list[str]: + if any(not item.strip() for item in value): + raise ValueError("Parquet paths must not be empty") + return value + + +class VerlOPDModelConfig(_StrictModel): + path: str = Field(min_length=1) + enable_gradient_checkpointing: bool = False + lora_rank: int = Field(default=16, ge=1) + lora_alpha: int = Field(default=32, ge=1) + target_modules: list[str] = Field(default_factory=list) + lora_adapter_path: str | None = None + + +class VerlOPDOptimConfig(_StrictModel): + lr: float = Field(gt=0) + weight_decay: float = Field(default=0.0, ge=0) + lr_warmup_steps: int = Field(default=0, ge=0) + + @model_validator(mode="after") + def _numbers_are_finite(self) -> VerlOPDOptimConfig: + _finite(self.lr, "actor_rollout_ref.actor.optim.lr") + _finite(self.weight_decay, "actor_rollout_ref.actor.optim.weight_decay") + return self + + +class VerlOPDActorConfig(_StrictModel): + optim: VerlOPDOptimConfig + loss_agg_mode: str = "token-mean" + use_kl_loss: bool = False + ppo_mini_batch_size: int = Field(default=1, ge=1) + ppo_max_token_len_per_gpu: int = Field(default=16384, ge=1) + use_dynamic_bsz: bool = False + + +class VerlOPDRolloutConfig(_StrictModel): + name: str + n: int = Field(default=1, ge=1) + temperature: float = Field(default=1.0, ge=0) + top_p: float = Field(default=1.0, gt=0, le=1) + tensor_model_parallel_size: int = Field(default=1, ge=1) + gpu_memory_utilization: float = Field(default=0.5, gt=0, le=1) + max_model_len: int | None = Field(default=None, ge=1) + max_num_batched_tokens: int | None = Field(default=None, ge=1) + max_num_seqs: int | None = Field(default=None, ge=1) + + @model_validator(mode="after") + def _numbers_are_finite(self) -> VerlOPDRolloutConfig: + _finite(self.temperature, "actor_rollout_ref.rollout.temperature") + _finite(self.top_p, "actor_rollout_ref.rollout.top_p") + _finite( + self.gpu_memory_utilization, + "actor_rollout_ref.rollout.gpu_memory_utilization", + ) + return self + + +class VerlOPDActorRolloutRefConfig(_StrictModel): + model: VerlOPDModelConfig + actor: VerlOPDActorConfig + rollout: VerlOPDRolloutConfig + + +class VerlOPDAlgorithmConfig(_StrictModel): + use_kl_in_reward: bool = False + + +class VerlOPDTeacherInferenceConfig(_StrictModel): + name: str + dtype: str = "bfloat16" + tensor_model_parallel_size: int = Field(default=1, ge=1) + data_parallel_size: int = Field(default=1, ge=1) + pipeline_model_parallel_size: int = Field(default=1, ge=1) + gpu_memory_utilization: float = Field(default=0.5, gt=0, le=1) + max_model_len: int | None = Field(default=None, ge=1) + + @model_validator(mode="after") + def _numbers_are_finite(self) -> VerlOPDTeacherInferenceConfig: + _finite( + self.gpu_memory_utilization, + "distillation.teacher_models.teacher_model.inference.gpu_memory_utilization", + ) + return self + + +class VerlOPDTeacherConfig(_StrictModel): + model_path: str = Field(min_length=1) + num_replicas: int = Field(default=1, ge=0) + inference: VerlOPDTeacherInferenceConfig + + +class VerlOPDTeacherModelsConfig(_StrictModel): + teacher_model: VerlOPDTeacherConfig + + +class VerlOPDLossConfig(_StrictModel): + loss_mode: str + topk: int = Field(ge=1) + use_task_rewards: bool = False + distillation_loss_coef: float = Field(default=1.0, ge=0) + loss_max_clamp: float | None = Field(default=None, ge=0) + log_prob_min_clamp: float | None = None + use_policy_gradient: bool = False + + @model_validator(mode="after") + def _numbers_are_finite(self) -> VerlOPDLossConfig: + _finite(self.distillation_loss_coef, "distillation_loss_coef") + if self.loss_max_clamp is not None: + _finite(self.loss_max_clamp, "loss_max_clamp") + if self.log_prob_min_clamp is not None: + _finite(self.log_prob_min_clamp, "log_prob_min_clamp") + return self + + +class VerlOPDDistillationConfig(_StrictModel): + enabled: bool + teacher_key: str = "data_source" + n_gpus_per_node: int = Field(default=1, ge=0) + nnodes: int = Field(default=1, ge=0) + teacher_models: VerlOPDTeacherModelsConfig + distillation_loss: VerlOPDLossConfig + + +class VerlOPDTrainerConfig(_StrictModel): + project_name: str = "miniverl" + experiment_name: str = "verl-opd" + save_freq: int = Field(default=-1, ge=-1) + test_freq: int = Field(default=-1, ge=-1) + total_epochs: int = Field(default=1, ge=1) + total_training_steps: int | None = Field(default=None, ge=1) + n_gpus_per_node: int = Field(default=1, ge=1) + nnodes: int = Field(default=1, ge=1) + + +class MiniVerlRuntimeExtensions(_StrictModel): + mode: RuntimeMode = "auto" + + +class MiniVerlMemoryExtensions(_StrictModel): + vram_limit_gib: float = Field(default=16.0, gt=0) + headroom_gib: float = Field(default=1.5, ge=0) + + @model_validator(mode="after") + def _numbers_are_finite(self) -> MiniVerlMemoryExtensions: + _finite(self.vram_limit_gib, "miniverl.memory.vram_limit_gib") + _finite(self.headroom_gib, "miniverl.memory.headroom_gib") + if self.headroom_gib >= self.vram_limit_gib: + raise ValueError("headroom_gib must be smaller than vram_limit_gib") + return self + + +class MiniVerlBatchingExtensions(_StrictModel): + rollout_batch_size: int = Field(default=1, ge=1) + teacher_score_batch_size: int = Field(default=1, ge=1) + update_trajectory_batch_size: int = Field(default=1, ge=1) + + +class MiniVerlTeacherAdapterExtensions(_StrictModel): + path: str | None = None + revision: str | None = None + + +class MiniVerlLocalExtensions(_StrictModel): + runtime: MiniVerlRuntimeExtensions = Field(default_factory=MiniVerlRuntimeExtensions) + memory: MiniVerlMemoryExtensions = Field(default_factory=MiniVerlMemoryExtensions) + batching: MiniVerlBatchingExtensions = Field(default_factory=MiniVerlBatchingExtensions) + teacher_adapter: MiniVerlTeacherAdapterExtensions = Field( + default_factory=MiniVerlTeacherAdapterExtensions + ) + + +class VerlOPDV08Profile(_StrictModel): + """The public, typed input surface; deliberately separate from RunConfig.""" + + data: VerlOPDDataConfig + actor_rollout_ref: VerlOPDActorRolloutRefConfig + algorithm: VerlOPDAlgorithmConfig + distillation: VerlOPDDistillationConfig + trainer: VerlOPDTrainerConfig + miniverl: MiniVerlLocalExtensions = Field(default_factory=MiniVerlLocalExtensions) + + +class OverrideRecord(_StrictModel): + expression: str + field: str + value: Any + + +class CompatibilityEntry(_StrictModel): + upstream_field: str + source_value: Any + local_target: str | None + classification: FieldClassification + reason: str + semantic_risk: Literal["none", "low", "medium", "high"] + user_confirmation_required: bool + executable: bool + + +class CompiledLocalExecutionPlan(_StrictModel): + schema_version: Literal[1] = 1 + profile: Literal["verl-opd-v0.8-single-gpu-v1"] = "verl-opd-v0.8-single-gpu-v1" + upstream: dict[str, str] + source_digest: str + compiled_digest: str + source_leaf_fields: list[str] + source: VerlOPDV08Profile + overrides: list[OverrideRecord] + compatibility: list[CompatibilityEntry] + local_execution: dict[str, Any] + executable: bool + + +@dataclass(frozen=True) +class _Rule: + target: str | None + classification: FieldClassification + reason: str + risk: Literal["none", "low", "medium", "high"] = "none" + confirmation: bool = False + + +def _rule( + target: str | None, + classification: FieldClassification, + reason: str, + risk: Literal["none", "low", "medium", "high"] = "none", + confirmation: bool = False, +) -> _Rule: + return _Rule(target, classification, reason, risk, confirmation) + + +_FIELD_RULES: dict[str, _Rule] = { + "data.train_files": _rule("source.train_files", "exact", "the same Parquet paths are consumed"), + "data.val_files": _rule("source.val_files", "exact", "the same validation paths are consumed"), + "data.prompt_key": _rule("source.prompt_key", "exact", "the same row field is selected"), + "data.train_batch_size": _rule( + "scheduler.logical_batch_size", + "locally_reinterpreted", + "logical examples per update; physical one-GPU batches are separately bounded", + "medium", + ), + "data.max_prompt_length": _rule("source.max_prompt_length", "exact", "same token bound"), + "data.max_response_length": _rule("source.max_response_length", "exact", "same token bound"), + "data.filter_overlong_prompts": _rule( + "source.filter_overlong_prompts", "semantically_conformant", "same filtering intent" + ), + "data.truncation": _rule("source.truncation", "semantically_conformant", "same named policy"), + "data.shuffle": _rule( + "source.shuffle", "semantically_conformant", "deterministic local shuffle" + ), + "data.seed": _rule("source.seed", "exact", "same integer seed"), + "actor_rollout_ref.model.path": _rule("student.model_id", "exact", "same model identity"), + "actor_rollout_ref.model.enable_gradient_checkpointing": _rule( + "student.gradient_checkpointing", "semantically_conformant", "same memory technique" + ), + "actor_rollout_ref.model.lora_rank": _rule( + "student.lora.r", "semantically_conformant", "same PEFT rank" + ), + "actor_rollout_ref.model.lora_alpha": _rule( + "student.lora.alpha", "semantically_conformant", "same PEFT scale" + ), + "actor_rollout_ref.model.target_modules": _rule( + "student.lora.target_modules", "semantically_conformant", "same module names" + ), + "actor_rollout_ref.model.lora_adapter_path": _rule( + "student.adapter.path", "exact", "same optional adapter artifact identity" + ), + "actor_rollout_ref.actor.optim.lr": _rule("optimizer.lr", "exact", "same learning rate"), + "actor_rollout_ref.actor.optim.weight_decay": _rule( + "optimizer.weight_decay", "exact", "same optimizer coefficient" + ), + "actor_rollout_ref.actor.optim.lr_warmup_steps": _rule( + "optimizer.lr_warmup_steps", "exact", "same optimizer-step count" + ), + "actor_rollout_ref.actor.loss_agg_mode": _rule( + "loss.reduction", "semantically_conformant", "token-mean is the only executable v0.8 mode" + ), + "actor_rollout_ref.actor.use_kl_loss": _rule( + "loss.actor_reference_kl", "exact", "must remain disabled for this profile" + ), + "actor_rollout_ref.actor.ppo_mini_batch_size": _rule( + "batching.update_trajectory_batch_size", + "locally_reinterpreted", + "used as a logical update batch, not a PPO mini-batch", + "high", + ), + "actor_rollout_ref.actor.ppo_max_token_len_per_gpu": _rule( + "batching.update_token_budget", + "locally_reinterpreted", + "one-GPU physical token budget", + "medium", + ), + "actor_rollout_ref.actor.use_dynamic_bsz": _rule( + "batching.dynamic_physical_batching", + "locally_reinterpreted", + "may change only physical execution", + "medium", + ), + "actor_rollout_ref.rollout.name": _rule( + "rollout.backend", + "locally_reinterpreted", + "source engine name is recorded; local execution does not claim vLLM/SGLang equivalence", + "high", + ), + "actor_rollout_ref.rollout.n": _rule("rollout.n", "exact", "one generation per prompt"), + "actor_rollout_ref.rollout.temperature": _rule( + "rollout.temperature", "exact", "same sampling value" + ), + "actor_rollout_ref.rollout.top_p": _rule("rollout.top_p", "exact", "same sampling value"), + "actor_rollout_ref.rollout.tensor_model_parallel_size": _rule( + "placement.tensor_parallel", "locally_reinterpreted", "must be one on one GPU", "high" + ), + "actor_rollout_ref.rollout.gpu_memory_utilization": _rule( + "memory.rollout_fraction", + "locally_reinterpreted", + "planner hint rather than an inference-server reservation", + "high", + ), + "actor_rollout_ref.rollout.max_model_len": _rule( + "rollout.max_model_len", "semantically_conformant", "same context ceiling" + ), + "actor_rollout_ref.rollout.max_num_batched_tokens": _rule( + "batching.rollout_token_budget", + "locally_reinterpreted", + "local padded-token budget", + "medium", + ), + "actor_rollout_ref.rollout.max_num_seqs": _rule( + "batching.rollout_batch_limit", "locally_reinterpreted", "local sequence cap", "medium" + ), + "algorithm.use_kl_in_reward": _rule( + "loss.kl_in_reward", "exact", "must remain disabled; pure OPD has no reward" + ), + "distillation.enabled": _rule("loss.enabled", "exact", "must be enabled"), + "distillation.teacher_key": _rule( + "teacher.routing_metadata", + "informational_only", + "single-teacher local execution does not route", + ), + "distillation.n_gpus_per_node": _rule( + "placement.teacher_phase", + "locally_reinterpreted", + "resource pool becomes one-GPU phases", + "high", + ), + "distillation.nnodes": _rule( + "placement.teacher_phase", + "locally_reinterpreted", + "resource pool becomes one local node", + "high", + ), + "distillation.teacher_models.teacher_model.model_path": _rule( + "teacher.model_id", "exact", "same teacher model identity" + ), + "distillation.teacher_models.teacher_model.num_replicas": _rule( + "placement.teacher_phase", + "locally_reinterpreted", + "one frozen teacher role, no replicas", + "high", + ), + "distillation.teacher_models.teacher_model.inference.name": _rule( + "teacher.backend", + "locally_reinterpreted", + "recorded without engine-equivalence claim", + "high", + ), + "distillation.teacher_models.teacher_model.inference.dtype": _rule( + "teacher.dtype", "semantically_conformant", "same requested numerical dtype" + ), + "distillation.teacher_models.teacher_model.inference.tensor_model_parallel_size": _rule( + "placement.teacher_tensor_parallel", "locally_reinterpreted", "must be one", "high" + ), + "distillation.teacher_models.teacher_model.inference.data_parallel_size": _rule( + "placement.teacher_data_parallel", "locally_reinterpreted", "must be one", "high" + ), + "distillation.teacher_models.teacher_model.inference.pipeline_model_parallel_size": _rule( + "placement.teacher_pipeline_parallel", "locally_reinterpreted", "must be one", "high" + ), + "distillation.teacher_models.teacher_model.inference.gpu_memory_utilization": _rule( + "memory.teacher_fraction", + "locally_reinterpreted", + "planner hint, not server reservation", + "high", + ), + "distillation.teacher_models.teacher_model.inference.max_model_len": _rule( + "teacher.max_model_len", "semantically_conformant", "same scoring context ceiling" + ), + "distillation.distillation_loss.loss_mode": _rule( + "loss.mode", "semantically_conformant", "dedicated pinned forward_kl_topk path" + ), + "distillation.distillation_loss.topk": _rule("loss.top_k", "exact", "same teacher top-k"), + "distillation.distillation_loss.use_task_rewards": _rule( + "loss.use_task_rewards", "exact", "must remain disabled" + ), + "distillation.distillation_loss.distillation_loss_coef": _rule( + None, "informational_only", "coefficient is inactive when task rewards are disabled" + ), + "distillation.distillation_loss.loss_max_clamp": _rule( + "loss.loss_max_clamp", "semantically_conformant", "same optional final clamp" + ), + "distillation.distillation_loss.log_prob_min_clamp": _rule( + "loss.log_prob_min_clamp", "semantically_conformant", "same optional log-prob clamp" + ), + "distillation.distillation_loss.use_policy_gradient": _rule( + "loss.use_policy_gradient", "exact", "must remain disabled for direct GKD OPD" + ), + "trainer.project_name": _rule("run.project", "exact", "same provenance label"), + "trainer.experiment_name": _rule("run.name", "exact", "same provenance label"), + "trainer.save_freq": _rule( + "checkpoint.interval", + "locally_reinterpreted", + "local optimizer-step interval", + "medium", + True, + ), + "trainer.test_freq": _rule( + "evaluation.interval", + "locally_reinterpreted", + "local optimizer-step interval", + "medium", + True, + ), + "trainer.total_epochs": _rule( + "schedule.dataset_passes", + "locally_reinterpreted", + "local bounded dataset passes", + "medium", + True, + ), + "trainer.total_training_steps": _rule( + "schedule.optimizer_steps", "semantically_conformant", "explicit global optimizer-step cap" + ), + "trainer.n_gpus_per_node": _rule( + "placement.device_count", "locally_reinterpreted", "always one local CUDA device", "high" + ), + "trainer.nnodes": _rule("placement.node_count", "locally_reinterpreted", "must be one", "high"), + "miniverl.runtime.mode": _rule(None, "informational_only", "miniVERL-only local extension"), + "miniverl.memory.vram_limit_gib": _rule( + None, "informational_only", "miniVERL-only planner limit" + ), + "miniverl.memory.headroom_gib": _rule( + None, "informational_only", "miniVERL-only planner headroom" + ), + "miniverl.batching.rollout_batch_size": _rule( + None, "informational_only", "miniVERL-only physical batch" + ), + "miniverl.batching.teacher_score_batch_size": _rule( + None, "informational_only", "miniVERL-only physical batch" + ), + "miniverl.batching.update_trajectory_batch_size": _rule( + None, "informational_only", "miniVERL-only physical batch" + ), + "miniverl.teacher_adapter.path": _rule( + None, "informational_only", "miniVERL-only teacher adapter extension" + ), + "miniverl.teacher_adapter.revision": _rule( + None, "informational_only", "miniVERL-only teacher adapter extension" + ), +} + + +def _flatten(value: Mapping[str, Any], prefix: str = "") -> dict[str, Any]: + result: dict[str, Any] = {} + for key, item in value.items(): + path = f"{prefix}.{key}" if prefix else str(key) + if isinstance(item, Mapping): + result.update(_flatten(item, path)) + else: + result[path] = item + return result + + +def _canonical_digest(value: Any) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + return hashlib.sha256(encoded).hexdigest() + + +_NUMERIC_FIELDS = { + path + for path in _FIELD_RULES + if path.endswith( + ( + ".lr", + ".weight_decay", + ".lr_warmup_steps", + ".train_batch_size", + ".max_prompt_length", + ".max_response_length", + ".lora_rank", + ".lora_alpha", + ".ppo_mini_batch_size", + ".ppo_max_token_len_per_gpu", + ".temperature", + ".top_p", + ".gpu_memory_utilization", + ".max_model_len", + ".max_num_batched_tokens", + ".max_num_seqs", + ".n", + ".topk", + ".distillation_loss_coef", + ".loss_max_clamp", + ".log_prob_min_clamp", + ".save_freq", + ".test_freq", + ".total_epochs", + ".total_training_steps", + ".n_gpus_per_node", + ".nnodes", + ".num_replicas", + ".tensor_model_parallel_size", + ".data_parallel_size", + ".pipeline_model_parallel_size", + ".vram_limit_gib", + ".headroom_gib", + ".rollout_batch_size", + ".teacher_score_batch_size", + ".update_trajectory_batch_size", + ) + ) +} + + +def _reject_non_finite_source_numbers(flat: Mapping[str, Any]) -> None: + spellings = { + "nan", + ".nan", + "+nan", + "-nan", + "inf", + ".inf", + "+inf", + "-.inf", + "-inf", + "infinity", + "+infinity", + "-infinity", + } + for path in sorted(_NUMERIC_FIELDS & flat.keys()): + value = flat[path] + if isinstance(value, float) and not math.isfinite(value): + raise ConfigError(f"verl OPD numeric field {path} must be finite") + if isinstance(value, str) and value.strip().lower() in spellings: + raise ConfigError(f"verl OPD numeric field {path} must be finite") + + +def _set_path(payload: dict[str, Any], path: str, value: Any) -> None: + parts = path.split(".") + if not all(parts): + raise ConfigError(f"invalid empty component in override field {path!r}") + current = payload + for part in parts[:-1]: + existing = current.get(part) + if existing is None: + existing = {} + current[part] = existing + if not isinstance(existing, dict): + raise ConfigError(f"override {path!r} crosses non-mapping field {part!r}") + current = existing + current[parts[-1]] = value + + +def parse_overrides(expressions: Sequence[str]) -> list[OverrideRecord]: + """Parse repeatable ``key=value`` overrides without Hydra or shell evaluation.""" + records: list[OverrideRecord] = [] + for expression in expressions: + if "=" not in expression: + raise ConfigError(f"override {expression!r} must use key=value syntax") + field, raw = expression.split("=", 1) + field = field.strip() + if not field or field.startswith("+") or field.startswith("~"): + raise ConfigError( + f"override field {field!r} is not supported", + hint="use an explicit existing dotted field without Hydra +/~ operators", + ) + try: + value = yaml.safe_load(raw) + except yaml.YAMLError as exc: + raise ConfigError(f"override {expression!r} has invalid YAML value: {exc}") from exc + reject_interpolation(value, label=f"override {field}") + records.append(OverrideRecord(expression=expression, field=field, value=value)) + return records + + +def _semantic_blocker(path: str, value: Any) -> str | None: + required = { + "distillation.enabled": (True, "distillation must be enabled"), + "distillation.distillation_loss.loss_mode": ( + "forward_kl_topk", + "only pinned forward_kl_topk is supported", + ), + "distillation.distillation_loss.use_policy_gradient": ( + False, + "policy-gradient OPD is unsupported", + ), + "distillation.distillation_loss.use_task_rewards": ( + False, + "task-reward mixtures are unsupported", + ), + "actor_rollout_ref.actor.use_kl_loss": ( + False, + "actor/reference KL loss is outside pure OPD", + ), + "algorithm.use_kl_in_reward": (False, "KL reward is outside pure OPD"), + "actor_rollout_ref.actor.loss_agg_mode": ( + "token-mean", + "only token-mean aggregation is supported", + ), + "actor_rollout_ref.rollout.n": (1, "one generation per prompt is required"), + "actor_rollout_ref.rollout.tensor_model_parallel_size": ( + 1, + "tensor parallelism greater than one is unsupported", + ), + "distillation.teacher_models.teacher_model.inference.tensor_model_parallel_size": ( + 1, + "teacher tensor parallelism greater than one is unsupported", + ), + "distillation.teacher_models.teacher_model.inference.data_parallel_size": ( + 1, + "teacher data parallelism greater than one is unsupported", + ), + "distillation.teacher_models.teacher_model.inference.pipeline_model_parallel_size": ( + 1, + "teacher pipeline parallelism greater than one is unsupported", + ), + "trainer.nnodes": (1, "multi-node execution is unsupported"), + } + expected = required.get(path) + if expected is not None and value != expected[0]: + return expected[1] + if path == "trainer.n_gpus_per_node" and value != 1: + return "more than one local training GPU is unsupported" + if path == "distillation.nnodes" and value not in {0, 1}: + return "multi-node teacher execution is unsupported" + return None + + +def compile_verl_opd_v08( + payload: Mapping[str, Any], + *, + overrides: Sequence[str] = (), + require_executable: bool = True, +) -> CompiledLocalExecutionPlan: + """Compile one resolved documented profile into a deterministic local plan.""" + merged = copy.deepcopy(dict(payload)) + reject_interpolation(merged, label="verl OPD config") + records = parse_overrides(overrides) + for record in records: + _set_path(merged, record.field, record.value) + reject_interpolation(merged, label="resolved verl OPD config") + + teachers = ( + merged.get("distillation", {}).get("teacher_models", {}) + if isinstance(merged.get("distillation"), Mapping) + else {} + ) + if isinstance(teachers, Mapping) and set(teachers) != {"teacher_model"}: + raise ConfigError( + "verl OPD config is not executable: multi-teacher routing is unsupported; " + f"found {sorted(map(str, teachers))}" + ) + + flat = _flatten(merged) + _reject_non_finite_source_numbers(flat) + unknown = sorted(path for path in flat if path not in _FIELD_RULES) + if unknown: + raise ConfigError( + f"verl OPD config is not executable: unsupported field {unknown[0]!r}", + hint="the profile accepts only its documented resolved verl v0.8 subset", + ) + try: + source = VerlOPDV08Profile.model_validate(merged) + except ValidationError as exc: + finite = any("finite" in error.get("msg", "").lower() for error in exc.errors()) + message = "verl OPD numeric fields must be finite" if finite else "invalid verl OPD profile" + raise ConfigError(message, hint=str(exc)) from exc + + compatibility: list[CompatibilityEntry] = [] + blockers: list[str] = [] + for path, value in sorted(flat.items()): + rule = _FIELD_RULES[path] + blocker = _semantic_blocker(path, value) + if blocker: + blockers.append(path) + classification: FieldClassification = "unsupported" + reason = blocker + risk: Literal["none", "low", "medium", "high"] = "high" + else: + classification = rule.classification + reason = rule.reason + risk = rule.risk + compatibility.append( + CompatibilityEntry( + upstream_field=path, + source_value=value, + local_target=rule.target, + classification=classification, + reason=reason, + semantic_risk=risk, + user_confirmation_required=rule.confirmation, + executable=blocker is None, + ) + ) + + executable = not blockers + local_execution = { + "device_count": 1, + "distributed_execution": False, + "compiler_scope": "config_semantics_only", + "model_weights_loaded": False, + "roles": ["actor_rollout", "teacher_scoring", "actor_update", "artifact"], + "runtime_mode": source.miniverl.runtime.mode, + "student_model": source.actor_rollout_ref.model.path, + "teacher_model": source.distillation.teacher_models.teacher_model.model_path, + "loss_mode": source.distillation.distillation_loss.loss_mode, + "loss_reduction": source.actor_rollout_ref.actor.loss_agg_mode, + "task_rewards": False, + "policy_gradient": False, + } + common = { + "upstream": {"repository": VERL_REPOSITORY, "tag": VERL_TAG, "commit": VERL_COMMIT}, + "source_digest": _canonical_digest(merged), + "source_leaf_fields": sorted(flat), + "source": source, + "overrides": records, + "compatibility": compatibility, + "local_execution": local_execution, + "executable": executable, + } + digest_payload = { + key: value.model_dump(mode="json") if isinstance(value, BaseModel) else value + for key, value in common.items() + } + digest_payload["overrides"] = [item.model_dump(mode="json") for item in records] + digest_payload["compatibility"] = [item.model_dump(mode="json") for item in compatibility] + plan = CompiledLocalExecutionPlan( + **common, + compiled_digest=_canonical_digest(digest_payload), + ) + if require_executable and not executable: + raise ConfigError( + "verl OPD config is not executable: unsupported semantics in " + ", ".join(blockers) + ) + return plan + + +def load_verl_opd_v08( + path: Path, + *, + overrides: Sequence[str] = (), + require_executable: bool = True, +) -> CompiledLocalExecutionPlan: + """Load a resolved YAML mapping; scripts and interpolations are never evaluated.""" + if path.suffix.lower() in {".sh", ".bash", ".ps1", ".cmd", ".bat"}: + raise ConfigError("verl OPD input must be resolved YAML, not a shell script") + try: + payload = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ConfigError(f"cannot read resolved verl OPD YAML {path}: {exc}") from exc + if not isinstance(payload, Mapping): + raise ConfigError("verl OPD YAML must contain one mapping") + return compile_verl_opd_v08( + payload, + overrides=overrides, + require_executable=require_executable, + ) diff --git a/src/miniverl/cli.py b/src/miniverl/cli.py index e598ab2..8e18aa3 100644 --- a/src/miniverl/cli.py +++ b/src/miniverl/cli.py @@ -1345,6 +1345,66 @@ def bridge_doctor_command( raise typer.Exit(1) +@bridge_app.command("compile-opd") +def bridge_compile_opd_command( + config: Path = typer.Option(..., "--config", help="Resolved verl v0.8 OPD YAML."), + profile: str = typer.Option( + "verl-opd-v0.8-single-gpu-v1", + "--profile", + help="Pinned miniVERL compatibility profile.", + ), + overrides: list[str] = typer.Option( + [], + "--set", + help="Repeatable dotted key=value override. No Hydra or shell evaluation.", + ), + out: Optional[Path] = typer.Option( + None, + "--out", + help="Atomically write the compiled machine-readable plan.", + ), + inspect_unsupported: bool = typer.Option( + False, + "--inspect-unsupported", + help="Return a non-executable report instead of failing on unsupported values.", + ), + as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."), +) -> None: + """Compile the pinned single-GPU verl v0.8 OPD config subset offline.""" + try: + from miniverl.bridge.opd_v08 import VERL_OPD_V08_PROFILE, load_verl_opd_v08 + + if profile != VERL_OPD_V08_PROFILE: + raise ConfigError( + f"unsupported OPD profile {profile!r}", + hint=f"use --profile {VERL_OPD_V08_PROFILE}", + ) + plan = load_verl_opd_v08( + config, + overrides=overrides, + require_executable=not inspect_unsupported, + ) + payload = plan.model_dump(mode="json") + if out is not None: + from miniverl.utils.runs import write_json_atomic + + write_json_atomic(out, payload) + except MiniVerlError as exc: + _fail(exc) + return + if as_json: + _emit_json(payload) + return + style = "green" if plan.executable else "red" + console.print(f"[{style}]config semantics executable: {str(plan.executable).lower()}[/{style}]") + console.print(f" profile: {_esc(plan.profile)}") + console.print(f" verl: {_esc(plan.upstream['tag'])} @ {_esc(plan.upstream['commit'][:12])}") + console.print(f" plan sha256: {_esc(plan.compiled_digest)}") + console.print(" runtime execution: not provided by this config-only command") + if out is not None: + console.print(f" written: {_esc(out)}") + + # -------------------------------------------------------------- benchmark diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 8d9b18e..6e1f74a 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -63,6 +63,7 @@ "export-benchmark", "schema", "bridge doctor", + "bridge compile-opd", "cache stats", "cache validate", } @@ -90,6 +91,30 @@ def _invoke(*args: str) -> Result: return CliRunner().invoke(app, list(args)) +def test_bridge_compile_opd_is_an_offline_deterministic_smoke(tmp_path: Path) -> None: + source = REPO_ROOT / "examples" / "verl-opd-v0.8-single-gpu.yaml" + output = tmp_path / "compiled-plan.json" + + result = _invoke( + "bridge", + "compile-opd", + "--config", + str(source), + "--set", + "distillation.distillation_loss.topk=64", + "--out", + str(output), + "--json", + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["profile"] == "verl-opd-v0.8-single-gpu-v1" + assert payload["executable"] is True + assert payload["source"]["distillation"]["distillation_loss"]["topk"] == 64 + assert json.loads(output.read_text(encoding="utf-8")) == payload + + def _probe_run_lock(output_root: str, run_id: str, result_queue) -> None: # type: ignore[no-untyped-def] """Spawn-safe probe used to prove an automatic report retains ownership.""" from miniverl.errors import RunLockedError diff --git a/tests/unit/test_verl_opd_v08_config.py b/tests/unit/test_verl_opd_v08_config.py new file mode 100644 index 0000000..ce4f2a4 --- /dev/null +++ b/tests/unit/test_verl_opd_v08_config.py @@ -0,0 +1,253 @@ +"""Typed, fail-closed compilation of the pinned verl v0.8 OPD subset.""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path + +import pytest + +from miniverl.errors import ConfigError + + +def _profile() -> dict[str, object]: + return { + "data": { + "train_files": ["data/train.parquet"], + "val_files": ["data/val.parquet"], + "prompt_key": "prompt", + "train_batch_size": 8, + "max_prompt_length": 256, + "max_response_length": 64, + "filter_overlong_prompts": False, + "truncation": "error", + "shuffle": True, + "seed": 17, + }, + "actor_rollout_ref": { + "model": { + "path": "Qwen/Qwen3-0.6B", + "enable_gradient_checkpointing": True, + "lora_rank": 16, + "lora_alpha": 32, + "target_modules": ["q_proj", "v_proj"], + "lora_adapter_path": None, + }, + "actor": { + "optim": { + "lr": "1e-5", + "weight_decay": 0.01, + "lr_warmup_steps": 0, + }, + "loss_agg_mode": "token-mean", + "use_kl_loss": False, + "ppo_mini_batch_size": 8, + "ppo_max_token_len_per_gpu": 2048, + "use_dynamic_bsz": True, + }, + "rollout": { + "name": "vllm", + "n": 1, + "temperature": 1.0, + "top_p": 0.95, + "tensor_model_parallel_size": 1, + "gpu_memory_utilization": 0.5, + "max_model_len": 320, + "max_num_batched_tokens": 2048, + "max_num_seqs": 8, + }, + }, + "algorithm": {"use_kl_in_reward": False}, + "distillation": { + "enabled": True, + "teacher_key": "data_source", + "n_gpus_per_node": 1, + "nnodes": 1, + "teacher_models": { + "teacher_model": { + "model_path": "Qwen/Qwen3-1.7B", + "num_replicas": 1, + "inference": { + "name": "vllm", + "dtype": "bfloat16", + "tensor_model_parallel_size": 1, + "data_parallel_size": 1, + "pipeline_model_parallel_size": 1, + "gpu_memory_utilization": 0.5, + "max_model_len": 321, + }, + } + }, + "distillation_loss": { + "loss_mode": "forward_kl_topk", + "topk": 32, + "use_task_rewards": False, + "distillation_loss_coef": 1.0, + "loss_max_clamp": None, + "log_prob_min_clamp": -10.0, + "use_policy_gradient": False, + }, + }, + "trainer": { + "project_name": "mini-verl", + "experiment_name": "opd-smoke", + "save_freq": 10, + "test_freq": 10, + "total_epochs": 1, + "total_training_steps": 2, + "n_gpus_per_node": 1, + "nnodes": 1, + }, + "miniverl": { + "runtime": {"mode": "auto"}, + "memory": {"vram_limit_gib": 16, "headroom_gib": 1.5}, + "batching": { + "rollout_batch_size": 2, + "teacher_score_batch_size": 2, + "update_trajectory_batch_size": 2, + }, + "teacher_adapter": {"path": None, "revision": None}, + }, + } + + +def test_compiler_is_typed_deterministic_and_classifies_every_source_leaf() -> None: + from miniverl.bridge.opd_v08 import VerlOPDV08Profile, compile_verl_opd_v08 + + first = compile_verl_opd_v08(_profile()) + second = compile_verl_opd_v08(copy.deepcopy(_profile())) + + assert isinstance(first.source, VerlOPDV08Profile) + assert first.executable is True + assert first.compiled_digest == second.compiled_digest + assert first.model_dump(mode="json") == second.model_dump(mode="json") + assert first.source.actor_rollout_ref.actor.optim.lr == pytest.approx(1e-5) + paths = {item.upstream_field for item in first.compatibility} + assert paths == set(first.source_leaf_fields) + classes = {item.upstream_field: item.classification for item in first.compatibility} + assert classes["data.train_files"] == "exact" + assert classes["actor_rollout_ref.rollout.name"] == "locally_reinterpreted" + assert classes["trainer.total_epochs"] == "locally_reinterpreted" + assert classes["miniverl.runtime.mode"] == "informational_only" + + +def test_override_precedence_and_scientific_notation_are_safe() -> None: + from miniverl.bridge.opd_v08 import compile_verl_opd_v08 + + plan = compile_verl_opd_v08( + _profile(), + overrides=( + "actor_rollout_ref.actor.optim.lr=2e-5", + 'data.train_files=["new.parquet"]', + "distillation.distillation_loss.topk=64", + ), + ) + + assert plan.source.actor_rollout_ref.actor.optim.lr == pytest.approx(2e-5) + assert plan.source.data.train_files == ["new.parquet"] + assert plan.source.distillation.distillation_loss.topk == 64 + assert [item.expression for item in plan.overrides] == [ + "actor_rollout_ref.actor.optim.lr=2e-5", + 'data.train_files=["new.parquet"]', + "distillation.distillation_loss.topk=64", + ] + + +@pytest.mark.parametrize( + ("path", "value"), + [ + ("distillation.enabled", False), + ("distillation.distillation_loss.loss_mode", "k3"), + ("distillation.distillation_loss.use_policy_gradient", True), + ("distillation.distillation_loss.use_task_rewards", True), + ("actor_rollout_ref.actor.use_kl_loss", True), + ("algorithm.use_kl_in_reward", True), + ("actor_rollout_ref.rollout.n", 2), + ("actor_rollout_ref.rollout.tensor_model_parallel_size", 2), + ( + "distillation.teacher_models.teacher_model.inference.data_parallel_size", + 2, + ), + ("trainer.nnodes", 2), + ], +) +def test_unsupported_algorithm_and_distributed_semantics_fail_closed( + path: str, value: object +) -> None: + from miniverl.bridge.opd_v08 import compile_verl_opd_v08 + + payload = _profile() + current: dict[str, object] = payload + parts = path.split(".") + for part in parts[:-1]: + current = current[part] # type: ignore[assignment] + current[parts[-1]] = value + + with pytest.raises(ConfigError, match="not executable") as exc_info: + compile_verl_opd_v08(payload) + assert path in str(exc_info.value) + + +def test_unknown_fields_and_multiple_teachers_fail_closed() -> None: + from miniverl.bridge.opd_v08 import compile_verl_opd_v08 + + unknown = _profile() + unknown["critic"] = {"strategy": "fsdp"} + with pytest.raises(ConfigError, match=r"critic\.strategy"): + compile_verl_opd_v08(unknown) + + multiple = _profile() + teachers = multiple["distillation"]["teacher_models"] # type: ignore[index] + teachers["teacher_model_2"] = copy.deepcopy(teachers["teacher_model"]) # type: ignore[index] + with pytest.raises(ConfigError, match="multi-teacher"): + compile_verl_opd_v08(multiple) + + +@pytest.mark.parametrize("value", ["${oc.env:MODEL}", "${model.path", "${HOME}/model"]) +def test_unresolved_interpolation_is_rejected(value: str) -> None: + from miniverl.bridge.opd_v08 import compile_verl_opd_v08 + + payload = _profile() + payload["actor_rollout_ref"]["model"]["path"] = value # type: ignore[index] + with pytest.raises(ConfigError, match="unresolved interpolation"): + compile_verl_opd_v08(payload) + + +@pytest.mark.parametrize("value", ["nan", ".inf", "-Infinity", float("nan")]) +def test_non_finite_numbers_are_rejected(value: object) -> None: + from miniverl.bridge.opd_v08 import compile_verl_opd_v08 + + payload = _profile() + payload["actor_rollout_ref"]["actor"]["optim"]["lr"] = value # type: ignore[index] + with pytest.raises(ConfigError, match="finite"): + compile_verl_opd_v08(payload) + + +def test_report_can_be_inspected_without_authorizing_unsupported_input() -> None: + from miniverl.bridge.opd_v08 import compile_verl_opd_v08 + + payload = _profile() + payload["actor_rollout_ref"]["rollout"]["n"] = 4 # type: ignore[index] + report = compile_verl_opd_v08(payload, require_executable=False) + + assert report.executable is False + item = next(x for x in report.compatibility if x.upstream_field.endswith("rollout.n")) + assert item.classification == "unsupported" + assert item.executable is False + assert "one generation" in item.reason + json.dumps(report.model_dump(mode="json"), sort_keys=True) + + +def test_packaged_resolved_fixture_compiles_and_shell_inputs_are_refused(tmp_path: Path) -> None: + from miniverl.bridge.opd_v08 import load_verl_opd_v08 + + root = Path(__file__).resolve().parents[2] + plan = load_verl_opd_v08(root / "examples" / "verl-opd-v0.8-single-gpu.yaml") + assert plan.executable is True + assert plan.upstream["commit"] == "7aed6b230776f963fa09509c10d9c3a767d1102c" + + script = tmp_path / "profile.sh" + script.write_text("echo never-executed", encoding="utf-8") + with pytest.raises(ConfigError, match="not a shell script"): + load_verl_opd_v08(script)