Code, data generators, and the trivial-baseline audit battery behind an ongoing study of change prediction in object-centric world models. An earlier version of this work appeared at WORLDS @ IROS 2026; its central claims did not survive proper baselines, and this repository now documents both what failed and what replaced it.
Change-detection benchmarks in object-centric manipulation are pervasively degenerate. Three successive benchmarks — motion-filtered, onset-filtered, interaction-filtered — each remove their predecessor's shortcut, and each is then won by a different one-line rule at F1 0.93–0.96. Five published object-centric world models plus our own lose to all three. The field does not run trivial baselines, and every "our model predicts what changes" claim in this literature is unvalidated until it does.
| benchmark | shortcut it removes | one-liner that wins instead | best trivial F1 | best learned gate |
|---|---|---|---|---|
| motion | — | already_moving (velocity) |
0.927 | 0.825 |
| onset | velocity | nearest_to_pusher (0 parameters) |
0.961 | 0.694 |
| interaction | geometry | moving_or_near_mover (propagated kinematics) |
0.955 | 0.772 |
The deliverable is experiments/onset_shortcut_audit.py — eleven parameter-free or
one-parameter rules, five of them invented against the interaction benchmark before it was
built — plus the methodological requirement that any new benchmark be audited with rules
invented against it, not against the one it replaces.
The degeneracy is structural rather than a bug in any filter: with a single point-like pusher,
the label is recoverable from one scalar (speed, or distance to the pusher, or distance to the
nearest mover), and a filter only chooses which. See experiments/RESULTS.md,
"Why it happens, and what a non-degenerate benchmark would need".
The repository still implements the sparse/residual model the earlier paper was about — a per-object change gate plus a residual delta head, so objects the gate leaves off are carried forward verbatim:
p̂ᵢ(t+1) = pᵢ(t) + gᵢ · Δᵢ , gᵢ ∈ {0,1} sampled with a Gumbel straight-through estimator
One mechanism claim survives contact with real baselines: error suppression on unchanged objects, where the gap over a dense monolith grows with object count (dense unchanged-L2 0.271 → 0.438 monotone from N=3 to N=20 while the sparse model stays flat), replicated in a second environment and unmatched by any of the five published baselines. Two related results also stand: the learned gate works as a local causal mask for counterfactual splicing, and velocity-free contact featurisation is what buys genuine onset prediction.
Three claims from the workshop paper are refuted, and are retained in RESULTS.md as
refutations rather than deleted: that the onset benchmark is a corrected benchmark (it
removes the velocity shortcut and nothing more); that only the sparse model plans (four of
five published models plan, and PETS beats it, 0.350 vs 0.250); and every headline number
computed on the leaky splits.
experiments/RESULTS.md is the authoritative results
document — read its "Status map" section before quoting any number from anywhere, including
from this README. SUBMISSION_RUNBOOK.md is the pre-submission
checklist.
docs/ is a GitHub Pages site that runs the actual trained weights against live
physics and scores them against the eleven trivial rules, frame by frame. Four tasks: a
sandbox you drive yourself, replay across the four engines, count transfer with one
checkpoint, and CEM planning through the model. No server and no CDN — the JavaScript forward
pass is checked against PyTorch to 1e-4 by tests/test_web_export.py.
python -m http.server 8000 --directory docs # then http://localhost:8000/A claim as uncomfortable as "a zero-parameter rule beats the model" is better checked than asserted, so the page lets a reader do exactly that.
pip install -r requirements.txtMuJoCo is needed only to generate data and to run the planning oracle; training and
evaluation on existing .npz files work without it.
| Path | What's in it |
|---|---|
models/ |
sparse_gating.py (change gate), sparse_residual.py (gate + delta head), dense_predictor.py (monolithic baseline), envs/mujoco_tabletop.py (procedural pushing env), layout.py (state layout) |
models/literature_baselines.py |
GNS/DPI-Net, C-SWM, SlotFormer, PETS, NPS — published models implemented from their papers |
models/envs/box2d_billiards.py, models/envs/pymunk_clutter.py |
Third-party-engine domains (Box2D, Chipmunk2D) |
models/envs/renderer.py |
State → image, so any existing dataset can be replayed from pixels |
models/slot_attention.py, models/keypoint_encoder.py |
The two unsupervised perception front ends |
models/checkpoints/ |
All trained checkpoints referenced by the paper |
experiments/ |
One script per experiment; see the table below |
experiments/RESULTS.md |
Full results, caveats, and reproduction commands |
experiments/paper_tables/ |
Machine-readable tables (.md / .csv) behind the paper |
experiments/runs/ |
Per-run config.json, metrics.csv, summary.json, figures |
tests/ |
pytest suite covering the metric and experiment code |
paper/ |
ICLR 2027 submission source (main.tex) and the official style files |
IEEE_Conference_Template/ |
Superseded — the WORLDS @ IROS 2026 workshop paper, kept for the record; its claims are the refuted ones |
| Script | Paper section |
|---|---|
generate_transitions.py, create_hard_subset.py, split_dataset.py |
Data pipeline (Sec. IV) |
build_clean_splits.py |
Episode-disjoint splits — use these for new work (see below) |
clean_pipeline.sh |
Retrain the sparse/dense baselines on the clean splits (VARIANT=dense for the packed variant) |
leakage_impact.py |
What the old split leak cost the published numbers |
dense_interaction_clean.py |
Dense-interaction control, rebuilt on clean splits |
aggregate_delta_head_study.py |
Merge delta-head runs into one multi-seed table |
counterfactual_augmentation.py |
CoDA-style augmentation from the learned gate (W3) |
scale_series_pipeline.sh |
Object-count series at one geometry, N=3..20 (W4) |
train_sparse_model.py, train_dense_baseline.py |
Training (Sec. IV) |
delta_head_study.py |
Distributional delta heads + multi-step rollout training (W1) |
compare_phase4_models.py, aggregate_seeds.py |
Prediction accuracy (Sec. V) |
param_matched_baseline.py |
"Not just smaller" control (Sec. VI) |
gate_ablation.py |
Object-centricity vs. change gate (Sec. VI); --extended adds the relational and soft-sparsity baselines |
oracle_gate_diagnostic.py |
Detection-vs-regression bottleneck (Sec. VI) |
rollout_horizon_error.py |
Multi-step rollout (Sec. VII) |
compositional_generalization.py |
Cross-count transfer (Sec. VII) |
sample_efficiency.py |
Sample efficiency (Sec. VII) |
planning_mpc.py |
Downstream CEM planning (Sec. VIII) |
make_paper_figures.py |
Paper figures |
domain_characterization.py |
Per-domain shortcut statistics; derives each domain's motion threshold |
cross_domain_pipeline.sh, cross_domain_analysis.py |
The shortcut across four physics engines, both benchmarks |
literature_baselines.py |
Five published models on change detection, native objectives, capacity-matched |
pixel_benchmark.py |
The whole study from 96×96 images instead of privileged state |
statistics.py |
Bootstrap CIs on differences + exact paired permutation tests |
onset_planar_pipeline.sh |
Planar onset benchmark at 8× episodes (fixes the underpowered replication) |
close_coverage_gaps.sh |
W3 at N=5/8, ratio sweep seeds 1–2, onset scaling regenerated at full episode count |
onset_shortcut_audit.py |
The trivial-rule audit battery — the paper's main deliverable. Eleven rules × any benchmark |
build_interaction_benchmark.py, interaction_pipeline.sh |
The third benchmark (indirect, chain-driven onset) and its audit |
momentum_shortcut.py |
The original finding: one line of code beats every learned model on the motion benchmark |
audit_battery.py |
Standalone entry point for auditing a third-party benchmark (see below) |
Every domain exposes the same observation dict and snapshot/restore/relocate_object
API, so the whole pipeline runs against any of them via --env:
--env |
engine | contact regime | motion threshold |
|---|---|---|---|
tabletop |
MuJoCo | impulsive 3D | 0.020 |
planar |
ours | quasi-static | 0.010 |
billiards |
Box2D | near-elastic | 0.031 |
clutter |
Chipmunk2D | high-friction clutter | 0.029 |
Thresholds are derived, not hand-tuned: domain_characterization.py sets each so the
motion filter retains the same fraction of steps the tabletop's 0.020 retains. The procedure
reproduces 0.020 for the tabletop and 0.010 for planar — both values that were originally
found by hand, one of them only after a shared threshold silently destroyed the planar splits.
experiments/audit_battery.py is the battery with the project dependencies stripped out, so
it runs on any change-detection benchmark. Give it one .npz per split holding
target_mask (R, N) and object_xy (R, N, 2), plus whichever of object_speed
(or object_vel), actuator_xy and actuator_xy_next you have — rules whose inputs are
missing are reported as skipped, never silently passed.
python -m experiments.audit_battery --test mybench_test.npz --val mybench_val.npz --train mybench_train.npz --model-predictions my_model_test_preds.npz --report audit_report.md --json audit_report.jsonThresholded rules fit their radius on validation and apply it unchanged to test, so they
get the same courtesy a learned model gets. The report ends in a verdict — DEGENERATE,
MARGINAL, or SURVIVES THESE RULES — and the last of those is deliberately not called a
pass: these are the eleven rules that broke our benchmarks, and the step that would have
saved us two of them is inventing rules against the benchmark under test. --extra-rules
takes a Python file defining rules(data, radius, rest_speed) -> dict for exactly that.
tests/test_audit_battery.py pins the battery against onset_shortcut_audit.py on a real
split, so the deliverable and the published experiment cannot drift apart.
The pipeline behind the published tables runs create_hard_subset then
split_dataset. That order leaks. create_hard_subset sets done=True at the end of
every kept chunk, so the later split_dataset call treats each chunk as an episode and
fingerprints it by that chunk's first row — mid-trajectory poses, not the episode's
initial configuration. Chunks from one trajectory then get different fingerprints and are
assigned to splits independently. The configuration_leakage: false guard does not catch
it, because those fingerprints genuinely differ.
Measured at 3 objects / seed 0: 62 of 247 source episodes (25%) have chunks in both train and test, and 107 span more than one split. Train and test hold states from the same rollout a few simulator steps apart.
python -m experiments.build_clean_splits --counts 3 5 8 --seeds 0 1 2reverses the order — split whole episodes first, filter within each split — and verifies
episode-disjointness on read-back. It writes data/transitions/splits_clean_{N}obj_s{S}/
containing _full_ splits (long contiguous runs, needed for rollout training) and _hard_
splits (motion-filtered, for the headline metrics), with the latter a subset of the former.
The original splits_{N}obj_s{S} directories are left alone so published numbers stay
reproducible. Numbers computed on clean splits are not comparable to them — anything
being compared has to be retrained there (bash experiments/clean_pipeline.sh N SEED).
Datasets are not tracked (~165 MB) but regeneration is seeded and deterministic. Build the three object counts across three seeds:
bash experiments/run_count_seeds.sh 3
bash experiments/run_count_seeds.sh 5
bash experiments/run_count_seeds.sh 8 0.22 0.09 # 8 boxes need wider bounds / tighter spacing
python experiments/aggregate_seeds.py # -> experiments/paper_tables/Then any individual study, e.g.:
python experiments/gate_ablation.py --counts 3 5 8
python experiments/param_matched_baseline.py --counts 3 5 8
python experiments/oracle_gate_diagnostic.py --counts 3 5 8experiments/RESULTS.md lists the full command for every table and figure, including the
held-out rollout sets and both rounds of the planning study.
pytest tests/A deliberately controlled study: the model consumes structured per-object state (poses and velocities), not pixels; scenes hold 3–8 rigid boxes; all networks are small MLPs (< 0.1 M parameters) that train, plan, and evaluate on a single laptop; and the planning result covers one push-to-goal task. The degeneracy finding is bounded the same way: it is established for single-pusher manipulation with local contact, across four engines, and the mechanism section says explicitly what a scene would need (multiple simultaneous actuators, occluded or long-range coupling, delayed effects) for the argument not to apply.
MIT — see LICENSE.