Benchmarking¶
miniVERL ships a controlled benchmark harness with an explicit comparison axis. It runs several training configurations ("arms"), rejects undeclared config differences before model allocation, and writes one JSON file and one Markdown file describing the results, complete resolved controls and measured quantities that were not matched.
Source files behind this document:
src/miniverl/evaluation/schema.py- the config and result schemassrc/miniverl/evaluation/benchmark.py- the harnesssrc/miniverl/evaluation/evaluator.py- standalone re-evaluationsrc/miniverl/evaluation/export.py- sanitized single-run exportsrc/miniverl/agent/loop.py-RolloutStats, where most metrics originaterecipes/benchmark_calc.yaml- the shipped example benchmark
Every metric miniVERL records, and its exact definition¶
Rollout metrics¶
These come from RolloutStats in src/miniverl/agent/loop.py. One
RolloutStats instance accumulates over a batch of trajectories; n below is
max(rollouts, 1).
| field | definition |
|---|---|
rollouts |
number of trajectories folded in |
solved |
trajectories whose verification.solved is true |
success_rate |
solved / n |
assistant_turns |
assistant generations that emitted at least one token |
emitted_tool_calls |
generations containing a <tool_call> opening marker |
parsed_tool_calls |
emitted tool-call blocks accepted by the protocol parser |
tool_execution_successes |
parsed calls whose environment step returned ok=true |
tool_execution_errors |
parsed calls whose environment step failed or raised |
unknown_tool_calls |
execution errors classified specifically as unknown_tool |
parse_errors |
assistant generations that parsed as neither a tool call nor a final answer |
repeated_call_terminations |
episodes stopped by the repeated-identical-call limit |
final_answers_emitted |
generations containing a <final> opening marker |
final_answers_format_valid |
final blocks accepted by the parser and not classified malformed by the verifier |
final_answers_verified |
parsed final blocks for which the verifier returned a record |
parse_valid_tool_call_rate |
parsed_tool_calls / emitted_tool_calls, or null when no tool block was emitted |
tool_execution_success_rate |
successes divided by successes + errors, or null when no call was executed |
tool_execution_error_rate |
errors divided by successes + errors, or null when no call was executed |
final_answer_format_validity_rate |
final_answers_format_valid / final_answers_emitted, or null when no final block was emitted |
avg_turns |
assistant_turns / n |
avg_tool_calls |
parsed_tool_calls / n |
generated_tokens |
sum of trajectory.generated_token_count |
generated_tokens_per_task |
generated_tokens / n |
tokens_per_solved_task |
generated_tokens / solved, or null when solved == 0 |
termination_reasons |
counter over TerminationReason |
failure_categories |
counter over FailureCategory, plus no_final_answer for trajectories with no verification record |
The old tool_call_count, valid_tool_call_rate and
invalid_tool_call_rate fields remain derived compatibility aliases for
schema-v1/v2 readers. New artifacts and reports use the precise fields above:
a parsed call with invalid arguments is one emitted call, one parsed call and
one execution error; it is never counted twice. A parse error is not an
execution error.
rollout.max_parse_errors is the maximum tolerated count. The episode stops
as soon as the count reaches that value: 0 stops on the first parse error and
2 stops on the second. Tool execution errors do not advance this counter.
TerminationReason is one of final_answer, max_turns, max_tokens,
parse_error_limit, repeated_call_limit, environment_error,
eos_without_final. FailureCategory is one of solved, wrong_answer,
no_final_answer, malformed_answer, invalid_tool_call, unknown_tool,
tool_error, budget_exhausted.
Evaluation payload¶
OPDTrainer.evaluate (src/miniverl/training/trainer.py) returns the
RolloutStats dictionary plus:
| field | definition |
|---|---|
tag |
label for this evaluation pass (baseline, final, cycleN, benchmark-<arm>, standalone) |
split |
train, eval or test |
tasks |
number of tasks evaluated, capped by eval.tasks (falling back to environment.eval_tasks) |
parameter_version |
successful student parameter updates completed at evaluation time |
policy_version |
deprecated alias of parameter_version |
global_optimizer_step / global_step |
optimizer steps completed at evaluation time |
rollout_policy_version |
parameter version that generated the most recently consumed rollout batch |
temperature |
eval.temperature; the shipped recipes use 0.0, which is exact argmax decoding |
seconds |
wall clock of the evaluation pass |
rollout_tokens_per_second |
generated_tokens / seconds |
success_by_difficulty |
mean solved rate grouped by task.difficulty |
memory |
CUDA counters from miniverl.utils.gpu.snapshot() |
Evaluation is deterministic per task: the seed for task i is
eval.seed + i, and greedy decoding at temperature: 0.0 removes the sampler
entirely.
Selection metrics¶
From aggregate_selection_stats in src/miniverl/selection/selectors.py,
written into each *_cycle record in metrics.jsonl:
| field | definition |
|---|---|
total_model_tokens |
model-generated target positions available |
selected_model_tokens |
positions actually sent to the teacher |
teacher_queried_position_ratio |
selected_model_tokens / total_model_tokens |
total_critical_tokens / selected_critical_tokens |
the same for tool-call and final-answer tokens |
selected_by_span_type |
selected positions grouped by span type |
The name teacher_queried_position_ratio is deliberate. Reducing selected
positions reduces LM-head projection work, cache size and the number of student
loss positions. It does not proportionally reduce teacher FLOPs, because
the teacher still runs a full forward pass over the whole sequence to produce
hidden states. See the module docstring of selection/selectors.py.
Cache metrics¶
From CacheCompressionStats in src/miniverl/schemas/cache.py:
| field | definition |
|---|---|
actual_bytes |
bytes actually written to the teacher-target cache |
theoretical_full_logit_bytes |
num_selected_positions * vocab_size * dtype_bytes_assumed |
compression_ratio |
theoretical_full_logit_bytes / actual_bytes |
bytes_per_selected_position |
actual_bytes / num_selected_positions |
The baseline is a dense [selected_positions, vocab] dump, not a
[batch, seq_len, vocab] dump. The larger baseline would make the ratio look
better and would not describe anything miniVERL ever writes.
Memory metrics¶
miniverl.utils.gpu.snapshot() reads torch.cuda.memory_allocated,
memory_reserved, max_memory_allocated and max_memory_reserved after a
torch.cuda.synchronize(), and reports cuda_available: false with zeroed
counters when there is no CUDA device. The benchmark harness takes the maximum
peak_allocated_bytes and peak_reserved_bytes over all metric records that
report cuda_available: true, and reports None for both when no record does.
The published v0.1/v0.2 result files predate destructive trainer teardown.
Their per-arm peak_allocated_bytes measurements came from live tensors during
that arm, but historical peak_reserved_bytes may include CUDA caching
allocator state retained from an earlier arm in the same process. The result
JSON is preserved rather than silently rewriting measured history. New harness
runs close each trainer inside a function-level context, drop model/optimizer
references, collect garbage, empty the CUDA cache, and only then construct the
next arm. Use new lifecycle-isolated runs for cross-arm reserved-memory
comparisons.
Per-arm result fields¶
ArmResult in src/miniverl/evaluation/schema.py is the full per-arm record:
| field | source |
|---|---|
name, description |
the arm definition in the benchmark YAML |
mode |
run.mode after merging the arm overrides |
seed |
the seed this repetition used |
run_id, run_dir |
the run directory this arm produced |
objective, opd_freshness, loss_mode, divergence, selector, top_k |
the mode-aware run manifest; SFT records sft_cross_entropy and null divergence/top-k |
resolved_config_digest, structured_diff |
compatibility fields for the fully defaulted pre-allocation arm config and its complete declared diff, including harness bookkeeping |
declared_config_digest, scientific_config_diff |
digest of the pre-allocation arm config and only the differences declared as experimental treatments |
runtime_resolved_config_digest, runtime_resolution_diff |
digest after auto decisions are frozen and changes such as models.device: auto -> cuda or memory.strategy: auto -> resident; these are execution provenance, not treatments |
harness_config_diff |
run name/seed/id and report toggles introduced by the benchmark harness; never interpreted as scientific differences |
| student/teacher model IDs and revisions, tokenizer fingerprint, context mode | the actual run manifest |
teacher_adapter |
validated adapter identity, hashes and policy evaluation, or null |
top_k |
manifest["objective"]["top_k"]: the student vocabulary size in exact_full_vocab mode, otherwise min(loss.top_k, vocab_size) |
optimizer_steps |
TrainResult.global_step |
policy_version |
backward-compatible alias of the final parameter version |
tasks ... tokens_per_solved_task |
the held-out evaluation payload above; tokens_per_solved_task is stored as null when it was NaN |
selected_training_tokens_total, model_generated_training_tokens_total, teacher_queried_positions_total |
numerators summed over every cycle; SFT teacher-query fields are null |
selected_position_ratio, teacher_queried_position_ratio |
ratios of summed numerators and denominators, never an average of cycle ratios |
cache_current_bytes, cache_bytes_written_total, cache_compression_ratio |
current cache footprint, cumulative bytes written despite pruning, and compression |
peak_allocated_bytes, peak_reserved_bytes |
maxima described above, null on CPU |
train_seconds, evaluation_seconds, wall_seconds |
separately measured phase and enclosing wall times |
baseline_success_rate |
reserved for a shared starting score. The harness currently always writes null: _cold_start runs with eval.enabled: false and returns no score, because the cold start is measured by the cold-start-only arm on the benchmark's own split instead |
measurement_status |
explicit status for time, VRAM, cache and policy-competence fields |
BenchmarkResult.aggregate() groups arms by name and reports
success_rate_mean, success_rate_min, success_rate_max, the seed count,
and a single_seed boolean. It computes no confidence intervals and no
p-values.
Explicit design and budget axis¶
Schema-v2 benchmark configs separate four layers:
base: ...
common_overrides: ...
cold_start_overrides: ...
allowed_differences: [...]
budget_axis: optimizer_steps
arms:
- name: ...
overrides: ...
Before a run directory is created or a model is loaded, run_benchmark
resolves the common config, the cold-start config and every arm for every seed.
It computes deterministic leaf-level structured diffs from the common config
and rejects any path not declared in allowed_differences. Harness-only paths
(run.name, run.seed, run.run_id, report.enabled) are allowlisted
internally.
New results record three disjoint views: scientific_config_diff for declared
treatments, runtime_resolution_diff for load-time decisions, and
harness_config_diff for bookkeeping. structured_diff remains as a
compatibility view for existing schema-v2 readers and is not labeled a
scientific diff.
The top-level controlled block points to the complete
common_declared_config and its digest; the legacy-named
common_resolved_config remains an identical compatibility field. Neither is
reconstructed from a hand-picked subset. The cold-start record carries its own
config, digest, environment/difficulty, checkpoint digest and time.
budget_axis: optimizer_steps means equal optimizer updates, not equal
compute. Quantities that cannot be matched by construction are measured:
- student generated tokens (
generated_tokens_per_task,tokens_per_solved_task) - selected training positions (
selected_training_tokens_total) - teacher query ratio (
teacher_queried_position_ratio) - teacher-cache current/cumulative bytes and compression ratio
- peak CUDA memory
- train, evaluation and wall time
An OPD arm samples its own trajectories, so it cannot produce the same token count as an SFT arm reading oracle traces. Reporting those numbers is the honest alternative to pretending they are equal.
Controls are enforced before allocation¶
An undeclared change in difficulty, schedule, effective batch size or any other
leaf is a ConfigError that explicitly says validation happened before model
loading. Intentional differences must be added to allowed_differences.
Different step counts can be declared—for example the zero-step
cold-start-only reference—but the result still records each actual count.
For recipes/benchmark_calc.yaml the budgets work out as follows, computed
from the merged configs rather than asserted:
| arm | mode | loss mode | divergence | configured top_k | selector | optimizer steps |
|---|---|---|---|---|---|---|
| (shared cold start) | sft | - | - | - | all_model_tokens | 450 |
cold-start-only |
sft | - | - | - | all_model_tokens | 0 |
sft-continued |
sft | - | - | - | all_model_tokens | 60 |
offline-kd |
offline_kd | bucketed_topk_tail | reverse_kl | 16 | all_model_tokens | 60 |
opd-bucketed-k16 |
opd | bucketed_topk_tail | reverse_kl | 16 | all_model_tokens | 60 |
opd-exact |
opd | exact_full_vocab | reverse_kl | 1 | all_model_tokens | 60 |
opd-bucketed-forward-kl |
opd | bucketed_topk_tail | forward_kl | 16 | all_model_tokens | 60 |
opd-tool-and-final |
opd | bucketed_topk_tail | reverse_kl | 16 | tool_and_final | 60 |
top_k: 1 on the exact arm is not a truncation. RunConfig normalizes
loss.top_k to 1 whenever loss.mode is exact_full_vocab, because top_k
has no meaning there; the manifest then records the student vocabulary size as
the effective top_k.
The step counts follow from rollouts_per_cycle: 8 and
gradient_accumulation_steps: 8 in the base recipe, which gives
ceil(8 / 8) = 1 optimizer step per cycle. Verify any benchmark's budget
before trusting it:
python - <<'PY'
from miniverl.evaluation.benchmark import resolve_benchmark_configs
from miniverl.evaluation.schema import BenchmarkConfig
spec = BenchmarkConfig.from_yaml("recipes/benchmark_calc.yaml")
_, cold, arms = resolve_benchmark_configs(spec)
print("cold", cold.environment.difficulty, cold.train.cycles)
for arm, cfg, diff in arms:
accum = cfg.train.gradient_accumulation_steps
per_cycle = max(1, (cfg.train.rollouts_per_cycle + accum - 1) // accum)
total = per_cycle * (cfg.train.cycles + cfg.train.sft_warmup_cycles)
print(f"{arm.name:26s} {cfg.run.mode.value:11s} steps={total} diff={len(diff)}")
PY
The shared cold-start checkpoint¶
cold_start_cycles in the benchmark YAML runs one SFT cold start per seed,
before any arm. _cold_start deep-merges these settings over the base recipe:
run: {mode: sft, seed: <seed>, name: "<benchmark>-coldstart"}
train: {cycles: <cold_start_cycles>, sft_warmup_cycles: 0,
eval_every_cycles: 0, save_every_cycles: 0}
cache: {reuse_across_policy_versions: false, strict_policy_version: true}
report: {enabled: false}
eval: {enabled: false}
The resulting checkpoints/final directory is loaded into every arm at that
seed. The cold start does not evaluate itself — eval.enabled: false above —
because on a real model each evaluation pass costs minutes of generation. The
shared starting score is instead the cold-start-only arm, which is evaluated
on the benchmark's own eval_split like every other arm.
The load is deliberately weights-only:
load_checkpoint(
checkpoint,
backend=trainer.student,
optimizer=trainer.optimizer,
device=trainer.student.device,
include_optimizer=False,
include_rng=False,
)
include_optimizer=False matters. If the cold start's Adam moments were
restored, every arm would inherit momentum pointing in the direction the SFT
cold start was already moving, which advantages whichever arm most resembles
that cold start. include_rng=False keeps each arm on the RNG stream implied
by its own seed rather than replaying the cold start's stream. Each load emits
a benchmark_cold_start_loaded event into the arm's events.jsonl with the
note weights only; optimizer state and RNG intentionally not restored.
Set cold_start_cycles: 0 to skip the cold start entirely; every arm then
starts from freshly initialized weights and shared_initial_checkpoint is
recorded as false.
Running a benchmark¶
# CPU, toy models
miniverl benchmark recipes/benchmark_calc.yaml --output runs/benchmarks
# One GPU, the pinned Qwen3 pair
miniverl benchmark benchmarks/configs/gpu_calc_hard.yaml --output runs/benchmarks
# Strictly offline after preloading every pinned model, tokenizer and adapter
miniverl benchmark benchmarks/configs/gpu_calc_hard.yaml \
--output runs/benchmarks --offline
# Continue a validated partial benchmark; existing arm directories otherwise fail
miniverl benchmark benchmarks/configs/gpu_calc_hard.yaml \
--output runs/benchmarks --resume
benchmarks/configs/gpu_calc_hard.yaml is the GPU counterpart: it uses
recipes/qwen_consumer_gpu_calc_raw_teacher.yaml as its historical-control
base, 12 cold-start cycles, the
test split, difficulty: hard, two prespecified seeds, and five arms:
cold-start-only, sft-continued, opd-raw-teacher,
opd-privileged-context and opd-protocol-sft-teacher. The final arm is gated
on a recorded teacher tool-policy evaluation; see
teacher-adapters.md. The primary config pins the public
Hub adapter to immutable revision
23323751318135484c06c043b1f9b9e7016dd89f; the _local_adapter config changes
only its source/path for offline use.
The completed two-seed RTX 4080 result is published as
gpu-calc-hard-equal-update-v2.json,
with a human-readable table
and SVG comparison. To rebuild the portable
artifacts from the preserved run directory:
python scripts/publish_benchmark_artifacts.py \
runs/benchmarks/gpu-calc-hard-equal-update-v2.json \
--results-dir benchmarks/results \
--svg docs/gpu-calc-hard-equal-update-v2.svg \
--driver-version 596.49
The publisher removes machine-local absolute paths before recomputing config digests, validates the schema-v2 model, and makes the chart carry a prefix of the exact source JSON SHA-256.
Options, all from src/miniverl/cli.py:
--output PATH- output directory; defaults tooutput_dirin the benchmark YAML (runs/benchmarksin the shipped example)--notes TEXT- free text stored in the result and rendered into the Markdown--offline- prohibit all network access and require every model, tokenizer and pinned adapter file to exist locally or in the Hugging Face cache--resume- intentionally resume validated cold-start/arm checkpoints after a partial run; without it, any arm-directory collision is a hard error--json- print the whole result as JSON instead of a table
miniverl benchmark requires the training extra
(python -m pip install ".[train]" from a source checkout); the CLI raises a
MissingDependencyError with the exact install command otherwise.
The example benchmark runs 7 arms at 2 seeds plus 2 cold starts, all on toy CPU models. Its wall clock on any particular machine is not measured here.
Writing your own benchmark config¶
schema_version: 2
name: my-comparison # 1-80 characters, used for the output filenames
description: >-
What question this comparison answers.
base: toy_cpu.yaml # path relative to this file, or an inline mapping
common_overrides:
environment: {difficulty: hard}
cold_start_overrides:
environment: {difficulty: medium} # explicit transfer design, if intended
cold_start_cycles: 200 # 0 disables the shared cold start
allowed_differences: [run.mode, train.cycles, loss.divergence]
budget_axis: optimizer_steps
eval_split: test # train | eval | test
seeds: [1234, 20260727] # at least one; more seeds means a variance range
output_dir: runs/benchmarks
arms:
- name: baseline # arm names must be unique
description: ...
overrides: {} # deep-merged into the base recipe
- name: variant
overrides:
loss: {divergence: forward_kl}
Each layer is deep-merged: nested mappings are merged recursively and everything
else is replaced. Every arm difference must be declared. The harness
force-overrides run.seed, run.name and report.enabled after arm overrides.
Validate the merged configs before spending GPU time; each arm is a normal
RunConfig and is rejected at parse time if the combination is contradictory
(for example run.mode: opd with cache.reuse_across_policy_versions: true).
Reading the output¶
run_benchmark writes two files into the output directory:
<name>.json- the fullBenchmarkResult<name>.md-render_benchmark_markdownoutput
It also leaves one complete run directory per arm per seed, named
<name>-<arm>-s<seed>, plus <name>-coldstart-s<seed> for each cold start.
Those directories hold the usual artifacts: config.original.yaml,
config.resolved.yaml, manifest.json, environment.json, metrics.jsonl,
events.jsonl, trajectories.jsonl, eval_trajectories.jsonl,
teacher-cache/, checkpoints/, eval.json.
The JSON¶
Top level (BenchmarkResult, extra="forbid"):
| key | contents |
|---|---|
schema_version |
2 for new harness output; preserved 1 artifacts remain readable |
miniverl_version |
the version that produced the file |
name, description, notes |
from the benchmark config and --notes |
created_at |
UTC ISO-8601, second resolution |
git_commit |
resolved by reading .git directly; null outside a checkout |
invocation, budget_axis |
exact command arguments and declared comparison axis |
hardware |
gpu, os, cpu_count |
software |
python version and the tracked package versions |
cold_start |
resolved config/digest, environment/difficulty, and per-seed checkpoint digest/time |
common_declared_config, common_declared_config_digest |
complete fully defaulted, pre-allocation shared config and SHA-256 |
common_resolved_config, common_resolved_config_digest |
compatibility aliases retained for existing schema-v2 readers |
controlled |
pointer/digest plus declared difference paths |
arms |
a list of ArmResult, one per arm per seed |
seeds |
the seed list |
To read it without writing code:
python -c "import json;d=json.load(open('runs/benchmarks/cpu-toy-calc-equal-update-v2.json'));\
print(json.dumps(d['controlled'], indent=2))"
The Markdown¶
<name>.md contains the version, git commit, budget axis, hardware, seeds, a
per-arm table and the resolved-control caveat. Complete resolved configs,
digests and structured diffs stay in JSON rather than being truncated into a
human table.
Two things in the rendering are load-bearing. When len(result.seeds) == 1
the header line gains **single seed -- no significance claimed**, and the
same warning is printed by the CLI. The paragraph after the controls block
states which quantities were measured rather than matched, so the table cannot
be quoted without its caveat.
Submitting a result¶
miniverl benchmark produces a multi-arm comparison. For a single run on your
own hardware, use the export command instead:
miniverl train recipes/qwen_consumer_gpu_calc.yaml
miniverl export-benchmark runs/<run-id> --notes "RTX 4080, driver 596.49"
export_run in src/miniverl/evaluation/export.py reads the run directory,
validates it, and writes benchmark-submission.json (or --out PATH)
containing a single-arm BenchmarkResult. It refuses to export a run with no
evaluation results and tells you to run miniverl eval --run <run-dir> first
or to set eval.enabled: true.
Sanitization is the point of that command. sanitize_hardware keeps only GPU
availability, name, VRAM, capability, driver version and device count, OS name
and release, machine architecture and CPU count. run_dir is reduced to the
directory name, so no absolute path from your machine survives. Nothing else
from the environment record is copied. Read the file before you publish it
anyway.
The JSON Schema for the result format is generated from the same Pydantic model that writes it, so the two cannot drift:
The repository ships configs, a byte-reproducible generated schema and
preserved CPU/RTX 4080 result artifacts. Historical v1 and the published v2
result stay byte-for-byte unchanged; the v1 migration/erratum and the v2
memory-provenance caveat are documented in benchmarks/README.md.
A submission should state, at minimum: the recipe used, the exact GPU and driver, the miniVERL version and git commit (all already in the file), and whether the arms were matched. If you changed the recipe, include the diff.
How to lie with these numbers, and how we avoid it¶
Every trap below is one that this harness makes possible. The mitigation is named in each case, and none of them is automatic.
Undeclared schedule differences¶
The lie. Give the arm you like more optimizer steps, or a larger effective batch, and report only final success rates.
How schema v2 prevents it. The complete resolved config for each arm is
diffed against the complete resolved common config before a model is loaded.
An arm cannot change train.cycles, train.rollouts_per_cycle,
train.gradient_accumulation_steps or any other leaf unless that path is
listed in allowed_differences.
What still needs review. A declared difference can still make a comparison
unfair. Every result row carries its own optimizer_steps, and the JSON stores
the exact structured_diff. Before quoting a table, check that the declared
budget_axis is the question you intended to ask and that the measured counts
and times support the comparison. The cold-start-only arm in the shipped
benchmark has 0 steps on purpose: it is the common starting point, not a
competitor.
Cherry-picked seeds¶
The lie. Run five seeds, report the best.
Why it remains possible. The harness faithfully runs the seeds listed in
seeds:; it cannot know whether that list was chosen after seeing results.
The mitigation. seeds is recorded at the top level of the result.
aggregate() reports success_rate_min and
success_rate_max next to the mean, and the Markdown renders all three, so a
single lucky seed inside a multi-seed arm is visible as a wide min-max range.
The number of seeds per arm is printed as its own column. If you rerun with a
different seed list, publish both files.
Teacher-FLOPs claims¶
The lie. "Selecting 30% of positions cuts teacher compute by 70%."
Why it is false. The teacher runs a full forward pass over the whole sequence regardless of how many positions are selected; selection only affects the LM-head projection, the cache payload and the number of student loss positions.
The mitigation. The metric is named teacher_queried_position_ratio and
never "teacher compute saved", and the docstring of
src/miniverl/selection/selectors.py states the accounting explicitly. If you
want a teacher-compute claim, measure wall clock and peak memory, both of which
the harness already reports per arm.
Single-seed significance¶
The lie. One seed per arm, a 4-point gap, and the word "improves".
The mitigation. aggregate() sets single_seed: true, the Markdown header
appends **single seed -- no significance claimed**, and the CLI prints:
single seed - no statistical significance is claimed. Add more entries to
`seeds:` for a variance estimate.
miniVERL computes no confidence intervals and no hypothesis tests; do not add them in prose.
Attributing a cold start's gains to the method¶
This one is not hypothetical. On the one measured 16 GB run of
recipes/qwen_consumer_gpu_calc_raw_teacher.yaml (run id
rtx4080-calc-opd), held-out
greedy evaluation on 12 calculator tasks went from 0.0 percent to 100.0 percent
across 16 optimizer steps in 481.1 s. The 8-cycle SFT cold start did most of
that work: the first OPD rollout batch already scored 83.3 percent. That run
demonstrates that the pipeline works end to end on a consumer GPU. It is not
evidence that OPD beats SFT, and the medium-difficulty calculator task
saturates.
The mitigation. The cold-start-only arm exists so the shared starting
point appears as a row in the table, evaluated on the same split as every other
arm, rather than as an unstated prior. Quote the delta over that row, not the
delta over zero.
Comparing across machines or commits¶
The mitigation. hardware, software, miniverl_version and
git_commit are recorded in every result file. Two results with different
values in any of those fields are not a matched comparison, whatever the
controlled block says.
Reporting a benchmark you did not run¶
measurement_status on every arm is measured, and
manifest["measurement_status"] in each run directory records
simulated_results: "none". There is no simulation path in this codebase; any
number presented as a miniVERL measurement should be traceable to a run
directory.
See also¶
docs/reproducibility.md- seeding, determinism, and reproducing a published rundocs/troubleshooting.md- what to do when a benchmark arm failsdocs/memory.md- memory strategies and the peak-VRAM numbers