verl bridge: portable artifacts, bounded semantics¶
miniVERL is an independent project; no endorsement by the verl project is
implied. The bridge targets the documented
single-gpu-online-distillation-v1 profile subset of
verl v0.8.0, pinned to
commit 7aed6b230776f963fa09509c10d9c3a767d1102c (7aed6b23). It is
miniVERL-defined compatibility Level 3, not full verl compatibility.
The solid arrows cover local artifact production, the portable bundle, and the pinned parse/load smoke. The dashed arrow is deliberate: no Ray, FSDP, vLLM or distributed verl job ran, and no miniVERL-OPD-to-verl-PPO semantic parity is claimed.
Compatibility state¶
| State | Current value | Meaning |
|---|---|---|
artifact_bundle_complete |
true |
PEFT, safetensors, Parquet, config and provenance are present and hashed. |
upstream_config_parse_passed |
false in a new bundle |
Set only by a separate pinned upstream smoke record, never inferred at export time. |
model_data_load_smoke_passed |
false in a new bundle |
The export itself does not load the base snapshot or execute a model. |
reward_implementation_complete |
false |
The generated reward function deliberately fails closed. |
launchable |
false |
Base weights, reward logic and confirmed mappings are incomplete. |
distributed_execution_tested |
false |
No distributed job ran. |
algorithm_semantic_parity |
false |
The target is a PPO/reward scaffold, not a continuation of miniVERL OPD. |
The committed pinned smoke record verifies a specific artifact-only upstream parse/load exercise. It remains separate from the readiness state of a newly exported bundle and from any execution claim.
Import a resolved profile subset¶
import-verl accepts the documented, resolved field subset—not arbitrary
Hydra/OmegaConf or verl YAML. With only a source profile, it writes
imported.import-report.json and a non-executable imported.template.yaml:
miniverl import-verl resolved-verl.yaml \
--profile single-gpu-online-distillation-v1 \
--target-verl v0.8.0 \
--out recipes/imported.yaml
The status is needs_user_input until the source or command determines the
training environment, qualified teacher, objective and schedule interpretation.
Parquet paths never silently select the calculator environment, and a same-base
standard teacher without a distinct model or adapter is never invented.
To deliberately produce a runnable recipe, supply the missing contract:
miniverl import-verl resolved-verl.yaml \
--profile single-gpu-online-distillation-v1 \
--target-verl v0.8.0 \
--environment jsonnav \
--teacher-model Qwen/Qwen3-1.7B \
--loss-profile topk-tail-reverse-kl \
--schedule-mapping epochs-as-cycles \
--out recipes/imported.yaml
The explicit schedule option acknowledges that verl epochs/save/test frequency
units are not proven equivalent to miniVERL cycles. Every source field is
classified as exact, derived, informational_only,
requires_user_confirmation or unsupported. In particular:
| Source field | Classification | Treatment |
|---|---|---|
data.train_files, data.val_files, data.prompt_key |
informational_only |
Recorded in the report; never substituted for a ToolEnvironment. |
data.max_response_length |
exact |
Copied to the per-turn response bound. |
data.max_prompt_length |
derived |
Combined with response length for miniVERL's total trajectory bound. |
| optimizer learning rate and seed | exact |
Copied after finite numeric validation. |
trainer.total_epochs, save_freq, test_freq |
requires_user_confirmation |
Copied only after the explicit schedule mapping. |
| algorithm, distributed or unknown fields | unsupported |
Rejected with a report. |
Finite scientific-notation strings such as 1e-5 are accepted; NaN and
infinity are rejected.
Unresolved interpolation is never executed¶
One recursive audit walks strings, lists, tuples and mappings, and runs at
three boundaries: the source fields, the explicit command-line choices, and the
generated recipe immediately before publication. Detection is conservative—any
${ in a reachable string is a finding, including unterminated and nested
forms. miniVERL never resolves an interpolation for you, so ${oc.env:TOKEN}
is an input defect rather than a lookup.
An exact, derived or requires_user_confirmation field carrying ${...}
fails closed and writes a rejection report. An informational_only field may
stay unresolved, but the report labels it and it never reaches executable
output:
"data.train_files": {
"classification": "informational_only",
"resolution_status": "unresolved_informational_only"
}
An accepted recipe is guaranteed to contain no interpolation token, and the
report records interpolation_audit.runnable_output_clean: true.
Outputs are stem-specific and transactional¶
One invocation owns one --out stem. For --out recipes/foo.yaml the only
files published are:
recipes/foo.yaml # or foo.template.yaml, never both as current outputs
recipes/foo.import-report.json
Dataset conversion behaves the same way, keyed on the requested Parquet path:
train.parquet, train.parquet.report.json and, when extensions exist,
train.parquet.miniverl.json.
Each invocation takes an exclusive per-stem reservation, refuses to start if any intended output path already exists, stages every file in a temporary sibling directory, and publishes the set with same-filesystem renames.
The guarantee is transactional publication with in-process rollback: if any
step raises, the previous family is restored and nothing partial is left
behind, so a report from one invocation cannot be paired with a recipe,
template, Parquet or sidecar from another. It is not multi-file crash
atomicity. A kill -9, a kernel panic or a power loss between two renames can
still leave a mixed family on disk; recovering from that would need a versioned
output directory behind a single atomically switched pointer, which miniVERL
does not implement. Re-running the invocation with --overwrite republishes a
coherent family.
An input file may never also be an output file. import-verl and
convert-dataset compare the source against every intended output — including
symlink, hard-link, relative and case-insensitive aliases — and refuse before
taking the reservation. --overwrite replaces a previous output family; it
never authorizes overwriting or deleting an input. There is no in-place mode.
Supplying --out does not imply replacement; pass --overwrite to replace an
existing family:
miniverl import-verl resolved-verl.yaml \
--profile single-gpu-online-distillation-v1 \
--target-verl v0.8.0 \
--environment jsonnav \
--teacher-model Qwen/Qwen3-1.7B \
--loss-profile topk-tail-reverse-kl \
--schedule-mapping epochs-as-cycles \
--out recipes/imported.yaml \
--overwrite
Every runnable output passes RunConfig validation before publication.
Export a portable bundle¶
miniverl export-verl --run runs/<run-id> \
--target-verl v0.8.0 \
--out exports/<bundle>
miniverl bridge doctor exports/<bundle> --require-verl
The bundle contains:
model/ adapter_config.json, adapter_model.safetensors, tokenizer metadata,
base-model.json (identity only; base snapshot is not bundled)
data/ train.parquet, val.parquet
recipe/ verl-overrides.yaml, launch.template.sh, REQUIRED_VERL.txt
reward/ reward_or_verifier_scaffold.py (fails closed)
provenance/ source manifest/result, compatibility-report.json, SHA256SUMS
README.md
Available source-run response length and learning rate are preserved in the
override file. The miniVERL total-token bound, cycle schedule and environment
identity are preserved in source_run_values; they are not relabelled as
equivalent verl intent. Any prompt limit or schedule value inserted for the PPO
scaffold appears in placeholder_defaults with source_run_intent: false.
bridge doctor verifies pins, standard adapter structure, tokenizer state,
Parquet schema, override structure, reward importability, privacy scopes and
hashes. An ok verdict means the artifact checks passed; it still returns
launchable: false while the fail-closed reward scaffold remains. The template
script also refuses to proceed without the immutable base snapshot and a
completed reward implementation.
Tokenizer verification levels¶
Filenames and digests are not a compatibility check, so tokenizer_identity
reports how far verification actually got:
| Level | Meaning |
|---|---|
not_present |
The bundle carries no tokenizer file. |
metadata_only |
Files exist but the vocabulary is missing, or no local load was performed. Missing components are named. |
loadable_local_snapshot |
AutoTokenizer.from_pretrained(..., local_files_only=True, trust_remote_code=False) succeeded. |
structural_identity_verified |
It loaded and its versioned structural digest, vocabulary size and special tokens match the identity recorded by the source run. |
Loading never contacts the network and never executes remote code. A structural mismatch fails closed. To require a real load rather than accept metadata:
The committed pinned smoke record predates these levels: its bundle ships only
tokenizer_config.json, which is metadata_only under the current check.
Reward code is inspected, never executed¶
A bundle is untrusted input. bridge doctor parses
reward/reward_or_verifier_scaffold.py with ast.parse and verifies the
interface statically; it never imports the module, so a bundle cannot act
merely by being diagnosed:
| Level | Meaning |
|---|---|
not_present |
No scaffold file, or it is unreadable. |
syntax_valid |
It parses as Python, but the interface check failed. |
interface_shape_verified |
A top-level compute_score(data_source, solution_str, ground_truth, extra_info=None) exists, is synchronous, is not bound by assignment, and no definition-time expression forbidden by this policy was found. |
trusted_dynamic_import_verified |
The module was actually imported. Reached only through an explicit opt-in. |
The default path stops at interface_shape_verified. The level is named for
what it proves: the interface has the expected shape. It is not a statement
that the file is safe to import.
Importing a module runs more than its top-level statements, so the check covers every definition-time position:
| Position | Example |
|---|---|
| Top-level statements | exploit(), non-literal assignments, loops |
| Class bases | class Hidden(exploit()) |
| Class keywords | class Hidden(object, metaclass=exploit()) |
| Annotations | def compute_score(data_source: exploit()), -> exploit(), VALUE: exploit() = 1 |
| Type parameters | Python 3.12 bounds and defaults |
| Decorators and defaults | @exploit(), extra_info=exploit() |
Ordinary type annotations and base classes are unaffected: only expressions
that would actually evaluate — calls, lambdas, comprehensions, await, walrus
— are rejected.
The signature contract is enforced including keyword-only parameters, so a
required keyword-only extra_info is refused: verl calls compute_score with
three positional arguments and that signature would raise TypeError.
Inspection is bounded — source bytes, AST node count, AST depth and the number
of reported findings — so a hostile scaffold produces a bounded diagnostic
rather than exhausting the process that asked for a diagnosis. Imports are
listed under imports_present with import_runtime_safety: not_verified,
because this check never runs them and an imported third-party module can do
anything; a relative, bundle-local import is refused outright.
What this proves is narrow: the interface is present and no forbidden definition-time expression exists. It proves nothing about whether the reward logic is correct, whether imported modules are side-effect free, or whether the file is safe to run later.
If you produced the bundle yourself and want the historical behaviour:
This executes the bundle's Python in your process with your privileges. It
prints a warning first and reports untrusted_code_executed: true. A
subprocess would not be a security sandbox either, so none is claimed.
What the bundle claims versus what was recomputed¶
provenance/SHA256SUMS lives inside the bundle it describes. Anyone who edits
compatibility-report.json can regenerate it, so agreement between them proves
internal consistency and nothing else. miniVERL implements no signature or
transparency-log verification, and does not pretend otherwise.
The diagnosis therefore reports three separate things:
| Field | Meaning |
|---|---|
bundle_declared_claims |
Copied from the bundle. Events this run did not observe. |
locally_recomputed_checks |
Performed in this process against the bytes on disk. |
provenance_trust |
unsigned_self_consistent at best; signature_verification: not_available. |
Historical smoke results, distributed execution and algorithm parity can only
ever be declared: no doctor run launches a job or compares algorithms, so the
top-level distributed_execution_tested and algorithm_semantic_parity flags
are always false regardless of what a bundle asserts. Checksum consistency,
config structure, the pinned requirement file, tokenizer load, adapter
structure, Parquet schema, the reward interface and the metadata privacy
heuristic are recomputed every time.
--require-verl recomputes the upstream check rather than trusting a record of
it: it loads the installed pinned verl's generated PPO config, parses the
bundle's verl-overrides.yaml and performs the structured merge in this
process. It still launches nothing.
Adapter weights are validated past the header¶
adapter_model.safetensors is checked structurally, not just parsed:
| Level | Meaning |
|---|---|
not_present |
The file is absent. |
header_only |
The header was read but rejected: bad dtype, impossible shape/byte arithmetic, unordered, overlapping, gapped or trailing offsets, or a payload shorter than the header declares. |
payload_structure_validated |
Offsets are contiguous and cover the data segment exactly. |
tensor_materialization_validated |
Every tensor also resolved through the official safetensors reader. |
A header-only result is never called loadable. To require a real payload:
The structural pass needs no optional dependency, so a torch-free install still
reaches payload_structure_validated and reports
official_reader_status: dependency_missing rather than pretending the file is
broken. --require-adapter-payload demands the strongest level, so it is not
satisfied when the official reader is unavailable — install the [bridge] extra
to use it.
Privacy is reported per inspection scope¶
The default run reads portable metadata files only. It therefore reports three independent statuses and never widens one into another:
portable_metadata_privacy: passed | failed
dataset_content_privacy: not_inspected
model_weight_privacy: not_inspected
not_inspected never means passed. An optional bounded scan inspects
string-like Parquet fields for URL userinfo, private-key blocks, access-key
ids, bearer tokens, credential assignments, absolute local paths and your own
sentinels:
It is a heuristic detector, not de-identification proof. It reports only the
detector category, split, column and row index—never the matched text—and never
reads .safetensors as text. Model-weight privacy stays not_inspected
because no meaningful check exists for it.
The bounds are enforced while reading, not after. Row groups are pulled one
at a time through ParquetFile.iter_batches, restricted to columns whose Arrow
type can contain a string, and decoding stops the moment max_rows or
max_bytes is reached; files past the bound contribute their footer row count
and are never decoded. The report states files_total, files_inspected,
row_groups_read, rows_scanned, rows_total, bytes_scanned and whether
the scope was full or sampled, so a sampled result is visibly sampled.
Schema validation reads the Parquet footer only.
Dataset conversion is complete-or-nothing¶
convert-dataset is lossless for the rows it accepts and refuses to quietly
drop the rest. One invalid row fails the whole conversion:
miniverl convert-dataset train.parquet --from verl-parquet --out out.parquet \
--allow-rejected-rows
Only with that flag does it publish a partial dataset, and the report then says
so: complete_dataset_conversion: false, lossless_for_accepted_rows: true,
plus the output-row-to-source-row index map so a dropped row stays traceable.
A row can carry miniVERL extension data in miniverl_extensions, the
conversion sidecar and extra_info.miniverl. Identical content in several
places is accepted and recorded as a deduplication; content that disagrees
fails closed, naming the row index and the source locations but never the
values, which may contain teacher targets.
Unsupported boundary¶
The bridge does not translate optimizer state, distributed RNG, FSDP or Megatron checkpoints, Ray state, PPO advantage/clipping semantics, GRPO group semantics, or a miniVERL teacher cache into PPO reference log-probabilities. See compatibility, launch requirements and the demo recording script.