Skip to content

Contributing to rlox

Thank you for your interest in contributing to rlox!

Development Setup

# Clone the repository
git clone https://github.com/wojciechkpl/rlox.git
cd rlox

# Create Python virtual environment
python3 -m venv .venv
source .venv/bin/activate

# Install dependencies
pip install maturin numpy gymnasium torch pytest ruff

# Build the Rust extension (always use --release)
maturin develop --release

# Verify
python -c "import rlox; print('rlox ready')"

Running Tests

# Rust tests. --no-fail-fast matters: without it cargo stops at the first failing
# test *binary* and later binaries' failures stay hidden.
cargo test --workspace --no-fail-fast

# Python tests — run `tests/`, not `tests/python/`, which is what CI runs.
# `tests/python/` alone collects 1583 of 2233 tests, skipping tests/agentic/ and
# the repo-hygiene guards. That narrower path is why a conftest collision between
# tests/agentic/ and tests/python/ went unnoticed locally while it was aborting
# collection for the entire suite in CI.
pip install -e ".[all]"          # after maturin develop
pytest tests/ -q

# Quick smoke test (skip slow integration tests)
pytest tests/ -m "not slow" -q

# Specific test file
pytest tests/python/test_offline_rl.py -v

# Quick algorithm smoke test
pytest tests/python/test_algorithm_smoke.py -v

The Linux-only sandbox tests

rlox-sandbox builds and runs on Linux only, and many of its tests need host capabilities a CI shared runner cannot provide. They are gated by two env vars (see crates/rlox-sandbox/tests/common/mod.rs):

Variable Unlocks Requires
RLOX_SANDBOX_CGROUP_TESTS every test that calls run_sandboxed and asserts on the exit status cgroup v2 self-migration — the process must already sit inside a user-delegated cgroup scope
RLOX_SANDBOX_ADVERSARIAL_TESTS tests that detonate real fork/memory/pids bombs the above, plus a scope-level TasksMax/MemoryMax backstop

Without them the sandbox child cannot enter its cgroup leaf, run_sandboxed returns SetupError("child could not write to cgroup.procs …"), and every containment assertion becomes vacuous — so those tests skip with an explicit SKIP <name>: … line (visible with -- --nocapture) instead of failing.

scripts/wk-sync-test.sh exports both and wraps the run in systemd-run --user --scope --slice=rlox.slice, so the full suite runs there:

bash scripts/wk-sync-test.sh 'cargo test -p rlox-sandbox --no-fail-fast'

The gates are explicit opt-ins rather than runtime probes on purpose: on the host that is supposed to have the capability, a regression must fail loudly instead of silently self-skipping.

Before you push

Run every CI gate locally in one command:

bash scripts/check-ci-local.sh          # all gates
bash scripts/check-ci-local.sh rust     # or just one half
SLOW=1 bash scripts/check-ci-local.sh   # + the convergence tests (~20-40 min)
WK=1 bash scripts/check-ci-local.sh     # + the Linux sandbox suite on wk-system

Use SLOW=1 before merging anything that touches an algorithm's training path or a convergence threshold. CI runs the convergence tests on pushes to main only, so a regression there cannot be caught by a PR — it turns main red after merge. That is exactly how TQC's convergence test broke main: the job had never run on the PR that introduced it.

Install the pre-push hook once and the fast gates run automatically:

bash scripts/install-git-hooks.sh

Code Style

# Rust
cargo fmt --all

# NOTE: plain `cargo clippy --workspace` FAILS on macOS — rlox-sandbox is
# Linux-only (namespaces, seccomp, cgroup v2) and its `seccompiler` dependency
# does not compile against a macOS libc. Lint it against a Linux target instead,
# otherwise every lint inside `#[cfg(target_os = "linux")]` stays invisible until
# CI runs. Both commands are what check-ci-local.sh does for you:
cargo clippy --workspace --exclude rlox-sandbox --all-targets
rustup target add x86_64-unknown-linux-gnu   # once
cargo clippy -p rlox-sandbox --all-targets --target x86_64-unknown-linux-gnu

# Python
ruff check python/
ruff format python/

The toolchain is pinned in rust-toolchain.toml so local clippy enforces exactly the lint set CI does. Clippy adds lints every release; an unpinned stable meant CI could fail on lints an older local toolchain never reported.

target-cpu=native and portability

.cargo/config.toml sets build.rustflags = ["-C", "target-cpu=native"], so local builds are tuned for your CPU. That must never leak into CI or a release: CI shares a build cache across runners with different CPU features (a proc-macro dylib from another runner kills rustc with SIGILL), and a published wheel has to run on any CPU of its architecture.

Every Rust-building workflow therefore sets CARGO_ENCODED_RUSTFLAGS: "". Use that spelling — CARGO_BUILD_RUSTFLAGS: "" does not work: cargo treats an empty value for that key as unset and falls back to the config file, so it is a silent no-op. tests/test_repo_hygiene.py enforces both halves of this.

Python version matrix

requires-python is >=3.10 and CI runs a 3.10–3.13 matrix, but your venv is a single version — so version-gated code fails only on CI. The classic case is a 3.11+ stdlib module imported without its 3.10 backport:

try:
    import tomllib          # stdlib from 3.11
except ModuleNotFoundError:  # Python 3.10
    import tomli as tomllib

rlox.config._load_toml implements this; reuse it where you can import from rlox. Two guards cover the gap: tests/test_repo_hygiene.py catches unguarded version-gated imports statically (any interpreter), and scripts/check-ci-local.sh runs tests/agentic/ on 3.10 in a throwaway uv venv. Both exist because this bug shipped in the rlox train --config x.toml CLI path and in the prime-rl launcher, where a broad except Exception turned the ModuleNotFoundError into a silent "reward 0.0".

Project Structure

crates/
  rlox-core/     # Rust data plane: buffers, envs, GAE, KL, pipeline
  rlox-nn/       # Backend-agnostic NN traits
  rlox-candle/   # Candle backend (inference + hybrid collection)
  rlox-burn/     # Burn backend (alternative)
  rlox-python/   # PyO3 bindings
python/rlox/
  algorithms/    # PPO, SAC, DQN, TD3, A2C, MAPPO, DreamerV3, IMPALA, offline RL, LLM
  offline/       # Offline RL base class + protocols
  exploration/   # Noise strategies + intrinsic rewards
  wrappers/      # VecNormalize and other env wrappers
  callbacks.py   # Training callbacks
  policies.py    # Neural network policies
  trainers.py    # High-level trainers for all algorithms
  runner.py      # Config-driven training (train_from_config)
  dashboard.py   # MetricsCollector, TerminalDashboard, HTMLReport
tests/python/    # Python test suite
docs/            # MkDocs documentation

Pull Request Process

  1. Create a feature branch from main
  2. Write tests first (TDD when possible)
  3. Ensure all Rust and Python tests pass
  4. Run cargo fmt and ruff format
  5. Update documentation if adding new features
  6. Keep PRs focused: one feature or fix per PR

Adding a New Algorithm

  1. Create python/rlox/algorithms/your_algo.py
  2. Implement using existing primitives (buffers, GAE, etc.)
  3. Add tests in tests/python/test_your_algo.py
  4. Add to docs/examples.md and docs/python-guide.md
  5. Update docs/index.md algorithm list

Adding a New Rust Primitive

  1. Implement in the appropriate crates/rlox-core/src/ module
  2. Add unit tests in the same file
  3. Create PyO3 bindings in crates/rlox-python/src/
  4. Register in crates/rlox-python/src/lib.rs
  5. Export in python/rlox/__init__.py
  6. Add Python integration tests