Reproducibility¶
This document describes exactly what miniVERL controls, what it records, what it does not control, and how to reproduce a published run.
Source files behind this document:
src/miniverl/utils/seeding.py- seeding, RNG capture and restoresrc/miniverl/utils/env.py- environment capture for manifestssrc/miniverl/utils/runs.py- run directory layoutsrc/miniverl/training/checkpoint.py- pickle-free checkpoints and resumesrc/miniverl/selection/selectors.py- deterministic position sub-samplingsrc/miniverl/models/sampling.py- the shared generation loop
Every source of randomness, and how it is controlled¶
| source | where | how it is controlled |
|---|---|---|
| Task generation | CalculatorEnvironment.generate_task and the other environments |
random.Random(f"calculator:{seed}:{difficulty}:{index}"), a string-seeded generator per task; the seed is environment.split_seed |
| Split membership | make_splits in src/miniverl/environments/base.py |
splits are built in the fixed order train, eval, test from index offsets 0, 1000000, 2000000, and any prompt already produced by an earlier split is skipped, so a task cannot leak from train into eval |
| Training task order | OPDTrainer._build_task_order |
random.Random(run.seed ^ 0x5EED).shuffle(order); the position in that order is task_cursor, which is checkpointed |
| Training rollout sampling | OPDTrainer._collect |
per-trajectory seed run.seed + global_step * 1013 + offset |
| Evaluation rollout sampling | OPDTrainer.evaluate |
per-task seed eval.seed + offset |
| Per-turn generation | RolloutRunner.rollout |
each turn calls the backend with seed * 1_000_003 + turn_id, so turns inside one trajectory do not share a stream |
| Token sampling | sample_from_logits in src/miniverl/models/sampling.py |
temperature <= 0.0 is exact argmax; otherwise a CPU torch.Generator seeded per generate() call |
| Teacher-position sub-sampling | select_positions |
random.Random(derive_seed(run_seed, trajectory_id)) |
| Toy model initialization | ToyBackend.__init__ |
a CPU generator seeded from run.seed (student) or models.teacher.toy_teacher_seed (teacher) draws one integer, which seeds a forked global torch RNG so initialization cannot disturb the ambient stream |
| Toy teacher fitting | OPDTrainer._prepare_toy_teacher |
wrapped in torch.random.fork_rng(devices=[]), because fit_toy_model calls torch.manual_seed and would otherwise clobber the stream the rollouts and a restored checkpoint depend on |
| LoRA dropout | models.student.lora.dropout |
defaults to 0.0, so the default configuration has no dropout randomness |
| Global RNG state | seed_everything(config.run.seed, deterministic=config.run.deterministic), called once in OPDTrainer.from_config |
seeds random, NumPy (seed % 2**32), torch CPU and all CUDA devices |
seed_everything with deterministic=True (the default, from
run.deterministic) additionally:
- sets
CUBLAS_WORKSPACE_CONFIG=:4096:8if it is not already set - sets
torch.backends.cudnn.deterministic = True - sets
torch.backends.cudnn.benchmark = False - calls
torch.use_deterministic_algorithms(True, warn_only=True)
warn_only=True is a deliberate choice, stated in the docstring: a kernel
without a deterministic implementation degrades to a warning rather than
crashing a long run. The use_deterministic_algorithms call is additionally
wrapped in contextlib.suppress(RuntimeError, AttributeError), so on a torch
build that rejects it the request is dropped rather than failing the run. Both
consequences are in
What is not deterministic.
The derive_seed scheme¶
def derive_seed(*parts: object) -> int:
import hashlib
blob = "|".join(str(p) for p in parts).encode("utf-8")
return int.from_bytes(hashlib.sha256(blob).digest()[:8], "big") >> 1
Parts are stringified, joined with |, hashed with SHA-256, and the first
eight bytes are read as a big-endian integer shifted right by one bit. The
shift yields a non-negative 63-bit value, which is inside the range every
random.Random and torch.Generator accepts on every platform.
The call site that matters is position sub-sampling:
Why a salted hash would break it¶
Python's built-in hash() for str and bytes is randomized per process by
PYTHONHASHSEED unless that variable is pinned. Seeding a sampler with
hash(trajectory_id) would mean the same trajectory selects different teacher
positions in a different process, on a different OS, or on a rerun of the same
command. The selection would be unreproducible, the teacher-target cache would
be keyed to positions that cannot be recomputed, and two runs with the same
run.seed would not be comparable.
SHA-256 has no such salt. Measured on this build:
$ PYTHONHASHSEED=1 python -c "from miniverl.utils.seeding import derive_seed; \
print(derive_seed(1234, 'calc-train-7:v0:s1234'), hash('calc-train-7'))"
7740022099863669268 4211654694876181374
$ PYTHONHASHSEED=2 python -c "from miniverl.utils.seeding import derive_seed; \
print(derive_seed(1234, 'calc-train-7:v0:s1234'), hash('calc-train-7'))"
7740022099863669268 -7731668170901394408
derive_seed is identical across both; hash() is not.
Why sampling always happens on the CPU¶
sample_from_logits filters on whatever device the logits live on, then moves
the probability vector to the host and draws there:
probs = torch.softmax(scaled, dim=-1).to("cpu")
return int(torch.multinomial(probs, num_samples=1, generator=generator).item())
Two reasons, both in the docstring:
- A CUDA tensor cannot be drawn with a CPU generator at all. Keeping the generator on the CPU and the draw on the CPU makes the seeding contract uniform across backends.
- A given seed produces the same token sequence on CPU and on GPU. Without this, a toy CPU run and a GPU run of the same recipe would diverge at the first sampled token even with identical seeds.
The cost is one device-to-host copy per generated token. Measured on this machine (RTX 4080, torch 2.13.0+cu130), a 151936-float device-to-host copy takes 0.11 ms, while a cached single-token decode step costs 30.9 ms. The copy is therefore a small fraction of a decode step, and decoding is kernel-launch bound rather than compute bound: a 14-token prefill costs 37.0 ms against 30.9 ms for a 1-token cached step.
Greedy evaluation (eval.temperature: 0.0) takes the torch.argmax branch and
never reaches the sampler at all.
What the manifest records¶
OPDTrainer.build_manifest() writes manifest.json in the run directory.
Measured on a real toy run, its top-level keys are:
created_at, deterministic, environment, git_commit, gpu, measurement_status,
memory, miniverl_version, mode, models, objective, os, os_release, packages,
platform, policy_version, python_version, run_id, run_name, seed
For writable runs, manifest.json is an atomic lifecycle record:
| status | meaning |
|---|---|
ready |
construction and startup provenance completed; train() has not begun |
running |
train() owns the operation and published this state before run_start |
completed / failed / interrupted |
terminal training outcomes |
closed_before_training |
a newly constructed writable run was closed from ready |
Evaluation-only attachment and terminal close do not rewrite the training
manifest. One OS-backed run lock is acquired before mutable run state and spans
training/resume, standalone checkpoint selection/validation/load/output, and
automatic HTML/Markdown report generation. Separately, one non-blocking
operation guard serializes every mutable-resource operation on an OPDTrainer:
training, evaluation, checkpoint save/load and destructive close. A failed
ownership attempt changes no lifecycle state or artifact. Loading checks READY
both before and after acquisition; evaluation restores the exact prior model
train/eval mode in finally; the training loop uses private owner-only
implementations for periodic work.
The load-bearing sub-objects:
environment- environmentnameand constructionparams, plusdifficulty,split_seed, and the realizedsplit_sizesper splitmodels-backend,device, and for student and teacher themodel_id,revision,tokenizer_revision,quantization,precisionand full backend capabilities; plustokenizer_fingerprintandtokenizer_vocab_sizeobjective-loss_mode,divergence,temperature,scale_by_temperature_squared, the effectivetop_k,jsd_beta,sampled_token_nll_weight,selector,selection_ratiomemory- the resolvedstrategy, thereasonstring explaining why,projection_chunk_size,oom_retries_usedandchunk_size_historypackagesandgpu- see belowmeasurement_status-cpu_metrics,cuda_metrics, andsimulated_results: "none"
environment.json is written alongside it by collect_environment():
cpu_count, git_commit, gpu, machine, os, os_release, packages, platform,
processor_family, python_implementation, python_version, tracked_env_vars
packages covers miniverl, torch, transformers, peft, accelerate,
bitsandbytes, safetensors, pydantic, typer, rich, numpy, with
null for anything not installed. tracked_env_vars records only the
allowlist in TRACKED_ENV_VARS, and only the entries that are actually set:
CUBLAS_WORKSPACE_CONFIG, PYTORCH_CUDA_ALLOC_CONF, CUDA_VISIBLE_DEVICES,
OMP_NUM_THREADS, TOKENIZERS_PARALLELISM.
git_commit is resolved by reading .git directly rather than by running a
subprocess, and is null outside a checkout (for example when miniVERL is
installed from a wheel). That is recorded honestly rather than faked.
Deliberately excluded, because a run directory is meant to be shareable: hostname, username, home directory, absolute paths outside the run, and every environment variable outside the allowlist. Canonical portable views also redact semantic credential components across snake/kebab/camel/Pascal keys, authorization/cookie/session fields, structured URL userinfo, and embedded Windows/UNC/POSIX absolute paths. Explicit tokenizer/token-count/session-count metadata remains readable. The private run files may retain values required for exact local resume, and redaction remains best-effort: never put real credentials in configs, run artifacts or reports.
Two caveats worth knowing before you quote the manifest:
manifest["measurement_status"]["cuda_metrics"]is"measured"whenever the machine has a visible CUDA device, not whenever the run used one. A CPU run on a GPU box reports"measured"here. The authoritative per-run signals aremanifest["models"]["device"]and, in an exported result, thecuda_availableflag computed byReportData.throughput(), which requires both acudadevice string and an available GPU.gpu.driver_versionis read throughpynvml, which is optional. It isnullwhenpynvmlis not installed.
The config layers have deliberately different meanings:
| file | contract |
|---|---|
config.submitted.yaml |
exact UTF-8 bytes, comments and formatting from a file-backed recipe; absent for programmatic configs |
config.validated.yaml |
canonical logical config after defaults and validation, before local-path and runtime resolution |
config.original.yaml |
compatibility layer used by v0.2 checkpoint resume: canonical runtime input with local adapter paths resolved but auto choices still unresolved |
config.resolved.yaml |
actual run id, device, memory strategy, chunk size and effective top-k used at runtime |
manifest.json names each layer, records its SHA-256, and uses manifest schema
3 for new v0.2.5 runs. Reports render a portable view of validated/resolved
configuration by default; private run files may retain the local path needed
for exact resume.
Re-verifying the pinned model revisions¶
recipes/qwen_consumer_gpu_calc.yaml pins both sides of the pair by commit
SHA. Re-check the pins against the Hugging Face API before trusting them.
Every command below was executed on 2026-07-27 and the output shown is what it
returned.
Revision exists (200 means the SHA resolves in that repository):
$ curl -s -o /dev/null -w "%{http_code}\n" \
https://huggingface.co/api/models/Qwen/Qwen3-0.6B/revision/c1899de289a04d12100db370d81485cdf75e47ca
200
$ curl -s -o /dev/null -w "%{http_code}\n" \
https://huggingface.co/api/models/Qwen/Qwen3-1.7B/revision/70d244cc86ccca08cf5af4e1e306ecf908b1ad5e
200
Negative control, so you know the endpoint really validates the revision rather than ignoring it:
$ curl -s -o /dev/null -w "%{http_code}\n" \
https://huggingface.co/api/models/Qwen/Qwen3-0.6B/revision/0000000000000000000000000000000000000000
404
tokenizer.json is byte-identical across the pair. The Hub exposes the file's
SHA-256 as X-Linked-ETag on the resolve endpoint:
$ curl -sI https://huggingface.co/Qwen/Qwen3-0.6B/resolve/c1899de289a04d12100db370d81485cdf75e47ca/tokenizer.json \
| grep -i x-linked-etag
X-Linked-ETag: "aeb13307a71acd8fe81861d94ad54ab689df773318809eed3cbe794b4492dae4"
$ curl -sI https://huggingface.co/Qwen/Qwen3-1.7B/resolve/70d244cc86ccca08cf5af4e1e306ecf908b1ad5e/tokenizer.json \
| grep -i x-linked-etag
X-Linked-ETag: "aeb13307a71acd8fe81861d94ad54ab689df773318809eed3cbe794b4492dae4"
Model configuration at those revisions (-L is required; the resolve endpoint
redirects to a CDN):
$ curl -sL https://huggingface.co/Qwen/Qwen3-0.6B/resolve/c1899de289a04d12100db370d81485cdf75e47ca/config.json \
| python -c "import sys,json;d=json.load(sys.stdin);print({k:d.get(k) for k in ('model_type','vocab_size','num_hidden_layers','hidden_size','tie_word_embeddings')})"
{'model_type': 'qwen3', 'vocab_size': 151936, 'num_hidden_layers': 28, 'hidden_size': 1024, 'tie_word_embeddings': True}
The 1.7B teacher at its pinned revision returns the same values except
hidden_size: 2048. Both repositories report apache-2.0:
$ curl -s https://huggingface.co/api/models/Qwen/Qwen3-0.6B \
| python -c "import sys,json;print(json.load(sys.stdin)['cardData']['license'])"
apache-2.0
Finally, verify the tokenizers behaviourally, which is what miniVERL actually
checks. HFTokenizerAdapter computes a fingerprint from the tokenizer class,
len(tokenizer), the EOS and PAD ids, the added special tokens, and the token
ids produced for a fixed probe string:
$ python -c "
from miniverl.models.tokenizers import HFTokenizerAdapter
a = HFTokenizerAdapter.load('Qwen/Qwen3-0.6B', revision='c1899de289a04d12100db370d81485cdf75e47ca')
b = HFTokenizerAdapter.load('Qwen/Qwen3-1.7B', revision='70d244cc86ccca08cf5af4e1e306ecf908b1ad5e')
print('vocab_size', a.vocab_size, b.vocab_size)
print('identical', a.fingerprint == b.fingerprint)
print(a.fingerprint)
"
vocab_size 151669 151669
identical True
f2f5e826dddc3ff1e2481111075f2ed6eced4e553168222d67650931d25be035
Note the two vocabulary numbers. config.json declares vocab_size: 151936
(the padded embedding matrix), while len(tokenizer) is 151669. miniVERL
records the tokenizer number as models.tokenizer_vocab_size in the manifest
and uses the model's own vocab_size for the LM-head projection.
The fingerprint value above was measured with transformers 5.14.1. It depends
on the tokenizer class name and the added-token list, so a transformers upgrade
can change it even when tokenizer.json does not. Compare fingerprints between
student and teacher within one environment; do not compare a fingerprint across
library versions.
What is not deterministic, and why¶
- Non-deterministic CUDA kernels.
torch.use_deterministic_algorithms(True, warn_only=True)warns instead of raising when an operation has no deterministic implementation, and the call itself is suppressed on builds that reject it. A GPU run can therefore contain non-deterministic reductions. Keeprun.deterministic: true(the default) and read the warnings; miniVERL does not suppress those. - Cross-hardware and cross-version floating point. Different GPU
architectures, driver versions, CUDA versions, cuBLAS versions and
transformers versions produce different last-bit results. The manifest
records all of these so a mismatch is visible; it cannot make them agree.
CUBLAS_WORKSPACE_CONFIGis recorded intracked_env_varsfor the same reason. - Reduced precision. The 16 GB recipe uses bf16 activations and NF4 weights. Both are lossy relative to fp32, and NF4 dequantization ordering is a bitsandbytes implementation detail.
cache.dtype: float16. The shipped GPU recipe stores teacher log-probabilities in fp16, which the config documents as costing roughly 1e-3 relative precision. Setcache.dtype: float32to round-trip the targets exactly.- Wall clock and throughput.
seconds,train_selected_tokens_per_second,rollout_tokens_per_secondand every memory counter are measurements of one machine at one moment. They are reported, never asserted. git_commitisnulloutside a git checkout, andgpu.driver_versionisnullwithoutpynvml.- Resume equality is numerical, not bitwise. See the next section.
Exact-resume guarantees¶
A new checkpoint directory holds three required files plus an optional
optimizer tensor file, and
tests/integration/test_resume_and_swap.py::test_checkpoint_files_are_pickle_free
asserts that the tensor/state payloads are pickle-free:
checkpoints/step-000012/
adapter.safetensors # trainable weights only
optimizer.safetensors # optimizer moment tensors; absent before any moments exist
state.json # everything that is not a tensor
checkpoint.json # written last: identity, sizes, SHA-256 and content digest
Tensors go through safetensors and structure goes through JSON. torch.save
is never used, so loading a checkpoint cannot execute code. The same test
asserts no safetensors file begins with a pickle protocol opcode. Checkpoints
are assembled in a sibling temporary directory, fully validated, then swapped
into place. Readers ignore incomplete temporary directories.
state.json carries, measured from a real run:
config_digest, cycle, global_step, metrics, miniverl_version,
offline_dataset_digest, parameter_version, resolved_config_digest,
optimizer_param_groups, optimizer_scalars, optimizer_state_keys,
policy_version, rng, rollout_iteration, rollout_policy_version, scaler,
scheduler, schema_version, task_cursor
rng contains python_state, torch_state, cuda_states and numpy_state.
capture_rng serializes Python's random.getstate() as JSON (keeping the
snapshot pickle-free), base64-encodes the torch CPU generator state and every
CUDA generator state, and captures NumPy's legacy 5-tuple state explicitly so
the layout cannot silently change to the dict form. restore_rng restores the
CUDA states only when the device count matches.
The test that proves it¶
tests/integration/test_resume_and_swap.py::test_uninterrupted_and_resumed_training_agree_exactly
runs four cycles in one process, then runs the same schedule with an
interruption after two cycles, a checkpoint, and a fresh trainer that resumes.
It asserts the final global_step, parameter_version, policy_version alias
and task_cursor match exactly, and that the largest absolute difference over
all trainable parameters is at most 1e-6.
That is a numerical tolerance, not bitwise equality. The module docstring of
src/miniverl/training/checkpoint.py says "match bit-for-bit" and names
tests/integration/test_resume.py; the test that exists is
test_resume_and_swap.py and its tolerance is atol=1e-6. Quote the
tolerance, not the docstring.
Three further tests in the same file:
test_checkpoint_round_trip_restores_optimizer_and_scheduleasserts the optimizer moments are non-empty before saving and non-empty after loading, and that the restored LR schedule returns the same learning rate at the restored steptest_resuming_a_checkpoint_from_a_different_config_is_refusedasserts aConfigErrorwhen the config digest differstest_swap_and_resident_produce_the_same_updateasserts the two memory strategies reach the same parameters within1e-6
The config digest, and the one trap in it¶
OPDTrainer._config_digest() is sha256(self.config.to_yaml()), and
load_from_checkpoint refuses to resume when the stored digest differs:
error the checkpoint was written by a different configuration
hint resume with the run's config.resolved.yaml, not a modified recipe
The digest is computed over the runtime-input config object,
which is preserved as config.original.yaml, not config.resolved.yaml. Because
config.resolved.yaml has run.run_id filled in (and memory.strategy,
loss.chunk_size and models.device resolved), it usually hashes differently.
Measured on a real run whose only original-versus-resolved difference was
run_id: null becoming run_id: docs-check, resuming from
config.resolved.yaml was refused and resuming from config.original.yaml
succeeded. Resume from config.original.yaml. Standalone evaluation uses
config.resolved.yaml, because it must reproduce the actual device/memory
choices without re-resolving auto.
Resuming¶
There is no miniverl resume subcommand. Resume is a library API:
from pathlib import Path
from miniverl.config import RunConfig
from miniverl.trainer import OPDTrainer
from miniverl.training.checkpoint import latest_checkpoint
run = Path("runs/<run-id>")
config = RunConfig.from_yaml(run / "config.original.yaml")
trainer = OPDTrainer.from_config(config, output_dir=run.parent, run_id="<run-id>-resumed")
state = trainer.load_from_checkpoint(latest_checkpoint(run / "checkpoints"))
print("resuming from step", state.global_step, "cycle", state.cycle)
trainer.train()
trainer.close()
Pass a new run_id, as above, unless you intend to write into the original
run directory: RunPaths.create uses exist_ok=True and will append to the
existing JSONL logs.
train() on a resumed trainer starts at state.cycle + 1, and emits a
resumed event noting that it is skipping the baseline evaluation and the SFT
cold start. Both are correct to skip: a "baseline" measured after training is
not a baseline, and repeating the cold start would double it.
Checkpoints are written by train.save_every_cycles (0 disables periodic
checkpoints) and always at the end of a run as checkpoints/final.
Reproducing a published run from its run directory¶
A run directory is self-describing. Given runs/<run-id>:
1. Read what was run.
python -c "import json;m=json.load(open('runs/<run-id>/manifest.json'));\
print(m['miniverl_version'], m['git_commit'], m['mode'], m['seed']);\
print(m['models']['student']['model_id'], m['models']['student']['revision']);\
print(m['models']['teacher']['model_id'], m['models']['teacher']['revision'])"
2. Match the software. environment.json lists the exact versions of
torch, transformers, peft, accelerate, bitsandbytes, numpy and
safetensors, plus the Python version and the tracked environment variables.
Install those versions, and check out git_commit of this repository.
3. Re-run the training. Use the resolved config, which has every auto
already frozen, and give it a new run id so it does not write into the original
directory:
config.resolved.yaml sets run.run_id, and make_run_id prefers an explicit
--run-id over it; without --run-id the reproduction would reuse the
original run id and write into the original directory.
4. Or re-evaluate only. To reproduce the reported numbers from the weights that were published, without retraining:
miniverl eval --run runs/<run-id>
# Require all reconstructed model, tokenizer and adapter files to be cached:
miniverl eval --run runs/<run-id> --offline
evaluate_run rebuilds the trainer from config.resolved.yaml (not the
original recipe, so any auto decision stays frozen), restores the latest
checkpoint with include_rng=False, evaluates deterministically, and writes
eval.<tag>.json next to the run. --split, --tasks, --checkpoint,
--out, --tag and --offline are available. Offline evaluation passes the
no-network policy into the reconstructed trainer, including any Hub teacher
adapter. It refuses to run against a directory with no config.resolved.yaml,
on the grounds that only runs created by miniverl train can be re-evaluated.
5. Compare. The comparison that matters is the evaluation payload:
tasks, success_rate, avg_turns, avg_tool_calls,
invalid_tool_call_rate, generated_tokens_per_task and
success_by_difficulty. Wall clock and memory will differ on different
hardware; see What is not deterministic
for which differences are expected.
See also¶
docs/benchmarking.md- controlled comparisons, explicit budget axes and metric definitionsdocs/troubleshooting.md- resume refusals, cache errors, tokenizer mismatchesdocs/limitations.md- what miniVERL does not support