Skip to content

perf(motion): fuse relative transforms in numba - #1321

Merged
TATP-233 merged 1 commit into
dev/issue-1316-update-state-numbafrom
perf/issue-1318-command-post-compute-numba
Aug 26, 2026
Merged

perf(motion): fuse relative transforms in numba#1321
TATP-233 merged 1 commit into
dev/issue-1316-update-state-numbafrom
perf/issue-1318-command-post-compute-numba

Conversation

@TATP-233

Copy link
Copy Markdown
Collaborator

Summary

  • fuse all normal-step and partial-reset MotionCommand relative/body-frame transforms into one prewarmed parallel Numba kernel
  • route BoxMotionCommand object pose/velocity through a second kernel backed by the same inline quaternion and inverse-rotation primitives
  • remove the replaced production NumPy implementations: motion transforms.py, motion observations.py, and the duplicate geometry anchor helper
  • eliminate partial-reset gather/scratch/scatter allocations while preserving caller-owned output buffers

Closes #1318
Parent roadmap: #1316
Milestone: M2 - Manager update_state throughput

Branch contract

  • Declared roadmap base: dev/issue-1304-motion-numba-body-state@9cca010d2c97910a6704a3da80b0d7f347103591
  • Integration base before this child: dev/issue-1316-update-state-numba@8018643edd33259ac0d6ac725dad418fea367bf1
  • Head: perf/issue-1318-command-post-compute-numba@a88aefe1
  • This PR does not target or modify main or dev/issue-1042-manager-based-api.

Performance

A/B baseline is the merged #1317 integration head. Same host and workload: g1_motion_tracking, 8192 envs, warmup 10, measure 100, fixed action/env seeds. Phase values are the median of three per-run medians; instrumented manager values are the median of three per-run means.

Backend Metric Before (ms) After (ms) Delta
MuJoCo command_manager.post_compute 4.297 0.659 -84.7%
MuJoCo update_state 14.251 10.718 -24.8%
MuJoCo env_step_total 76.123 73.543 -3.4%
MJWarp command_manager.post_compute 4.217 0.589 -86.1%
MJWarp update_state 12.814 9.431 -26.4%
MJWarp env_step_total 43.371 39.651 -8.6%

MJWarp transfer-inclusive accounting remains stable:

Metric Before (ms) After (ms)
backend_physics_ms 13.592 13.637
backend_host_cache_refresh_ms 0.658846 0.659134
backend_control_upload_ms 0.101911 0.101441

The D2H/PCIe refresh remains inside backend.step() and therefore inside env_step_total_ms; it is reported separately and not duplicated in update_state.

Validation

  • uv run pytest -q tests/tasks/test_motion_term_parity.py tests/envs/test_motion_command_partial_reset.py tests/envs/test_observation_partial_reset.py tests/envs/test_env_configs.py — 60 passed
  • additional geometry/package/import tests — 16 passed
  • make test-all — passed on final head: 2303 passed, 28 skipped, 281 deselected, 1 xfailed; mypy, pyright, Ruff, and benchmark import smoke passed
  • randomized kernel parity covers full batch, selected rows, untouched rows, input immutability, buffer reuse, and Box object state
  • working tree was clean before PR creation

This is a non-main child PR. Per #1313 governance, the complete gate is local make test-all; remote CI is intentionally not scheduled or awaited.

@TATP-233
TATP-233 requested a review from caozx1110 as a code owner August 26, 2026 11:52
@TATP-233
TATP-233 merged commit 1d18060 into dev/issue-1316-update-state-numba Aug 26, 2026
@TATP-233
TATP-233 deleted the perf/issue-1318-command-post-compute-numba branch August 26, 2026 15:21
TATP-233 added a commit that referenced this pull request Sep 4, 2026
* perf(tasks,managers): eliminate NumPy temporaries in motion tracking hot terms (#1296)

- MotionCommand._refresh_robot_state: single-gather np.take/np.ix_ into the
  destination buffers instead of src[sel][:, ids] double copies.
- _BodyTerm error_exp terms: shared per-instance scratch for the
  subtract/square/sum chain; exp(-e/s^2) computed in place. Same op order,
  bit-identical results.
- joint_pos_limits and the global-anchor/joint error_exp function terms:
  in-place chaining, no temporaries beyond the returned buffer.
- RewardManager.compute: weighted term value goes through a shared scratch
  buffer (result-dtype aware) with out= writes into _step_reward.
- ObservationManager.compute_group: skip the defensive obs.copy() when the
  noise stage already returned a fresh array.

Parity: new tests/tasks/test_motion_term_parity.py asserts bit-identical
outputs vs the naive expressions; existing env/manager integration suites
all pass.

* fix: narrow scratch typing for pyright; apply ruff format

* docs: regenerate support matrix for g1_motion_tracking motrix=Configured

Pre-existing staleness from the mjwarp wiring commit (77ca6dd9); caught by
make test-all's check_docs gate.

* perf(reset): row-scope reset-path body getters and complete mjwarp set_state timing (#1295)

- Surface ResetStateTransaction commit timings (outer wall-clock + backend
  set_state sub-keys) into info["timing"] for manager-based envs; previously
  the backend timing dict was discarded and all set_state_* keys sampled as 0.
- mjwarp set_state now reports the shared keyset with granular
  set_state_reset_upload_ms / set_state_reset_forward_ms /
  set_state_host_cache_refresh_ms (mujoco/motrix emit 0.0 placeholders for
  column stability); legacy collapsed keys removed, stale-key leak fixed by
  filtering collected keys against RESET_DONE_DETAIL_TIMING_KEYS.
- Add SimBackend.get_body_lin_vel_w_rows / get_body_ang_vel_w_rows with
  row-gather overrides on mujoco/motrix; MotionCommand._refresh_robot_state
  uses them on the partial-reset path instead of full-batch reads + slice.
- Wrap reset command/obs compute in scene._scoped_state_reads so repeated
  getter calls across terms are deduplicated.
- Benchmark: extend NP_ENV_STEP_TIMING_KEYS / CSV fields and add the mjwarp
  set_state detail table.

Same-host sac/g1_motion_tracking (8192 envs) reset_done: mujoco 9.38 -> 6.92
ms (-26%), motrix 20.63 -> 13.3-14.2 ms (-31~35%), mjwarp ~flat (set_state
device forward 5.2 ms of 14.6 ms dominates; now quantified via new keyset).

* chore: remove temporary per-term profiling instrumentation (#1292 closeout)

The PROFILING_TEMP hooks from #1293 served their purpose (hot-term report
in https://github.com/unilabsim/UniLab/issues/1293#issuecomment-5408865950)
and are removed now that #1294/#1295/#1296 are merged. Removes
src/unilab/utils/term_profiling.py, its tests, the utils whitelist entry,
and all call sites; no functional change to manager logic.

* Revert "Merge PR #1298: perf(managers): per-term isfinite 扫描降级为采样/可配置(#1294)"

This reverts commit c691a61377fb7b2a3eb49c8cc7d8fbb58be47a17, reversing
changes made to 2f7d97f4b22f47c435332f2bf01965394295f342.

* perf(motion): move fixed hot terms to numba kernels

* perf(body-state): fuse selected body cache copies

* refactor(motrix): drop temporary body-state kernel

* perf(motion): fuse command metrics in numba (#1317) (#1320)

* perf(motion): fuse relative transforms in numba (#1318) (#1321)

* perf(observations): reduce batch pipeline copies (#1319) (#1322)

* perf(env): confine DP collector host compute to the per-rank CPU block

Multi-rank off-policy collectors pin their MuJoCo BatchEnvPool workers to
a per-rank CPU block via EnvCfg.cpu_ids, but the collector's host-side
compute did not follow: Numba's parallel kernels sized their pool from the
host CPU count and drifted across rank boundaries, and the OpenBLAS pool
spawned at import kept the host-wide mask.

NpEnv.__init__ now applies apply_env_cpu_runtime(cfg.cpu_ids) on the cold
path: the process is confined to the block (existing threads pinned
individually via /proc/self/task, later threads — including Numba's
lazily-launched pool — inherit the mask) and Numba's pool is sized to
len(cpu_ids) unless NUMBA_NUM_THREADS is set explicitly. cpu_ids=None keeps
the single-rank path bit-identical. Backend-agnostic: any env declaring
cpu_ids (e.g. motrix once it grows affinity support) gets the same
confinement.

* perf(mjwarp): reduce motion tracking reset latency

* benchmark(env): add MuJoCo pool thread-scaling and env-step phase-CPU probes (#1328)

Diagnostic probes for the SAC/MuJoCo single-GPU collector CPU
under-utilization report: pool thread-count scaling on the G1 scene, and
per-phase wall/CPU attribution of a full task env step. New files only;
no behavior change.

* fix(base): type-check and test cpu_runtime on non-Linux hosts

os.sched_setaffinity/sched_getaffinity are Linux-only, so mypy on darwin
rejected the direct attribute access (attr-defined) and the unit tests'
monkeypatch.setattr/delattr failed because the attributes do not exist.

Resolve the affinity symbols via getattr at call time (identical runtime
semantics, still monkeypatchable) and pass raising=False to the test
monkeypatch seams so they work whether or not the host exposes them.

* fix(logging): make collector reward reporting timely

Reward displays (tensorboard reward/mean and the terminal logger) lagged
badly on off-policy and APPO runs:

- collectors sent metrics only every num_envs * 10 env steps, so the
  reported reward changed just once per ~10 learner iterations;
- runners then averaged the last 100 (off-policy) or 50 (APPO) reports,
  each already a rolling 100-episode mean, delaying the visible curve by
  ~1000 iterations.

Report metrics every collector cycle, keep the runner-side window at the
last 10 reports, and bound the per-worker episode reward/length buffers
with deque(maxlen=100) instead of lists that grew for the whole run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(env): keep per-step reward log entries through autoreset

ManagerBasedRlEnv.reset() replaced state.info["log"] with the reset-only
extras (Episode_Reward/*), wiping the fresh per-step reward/* entries that
_update_state_in_read_phase() had just computed for the current transition.
On any step where at least one env resets — with thousands of envs, nearly
every step — collectors therefore saw no reward/* keys at all, so the
per-term reward components in tensorboard and the terminal logger stayed
frozen at one stale value for thousands of iterations (observed as long
flat staircases on reward/motion_* etc.).

Merge instead of replace on the autoreset path: the pre-reset per-step
entries stay, reset extras layer on top. Standalone (non-autoreset) resets
are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(mujoco): size default pool threads to effective CPUs, not 2x oversubscription (#1328)

Single-GPU (cpu_ids=None) BatchEnvPool sizing now uses the CPUs actually
usable by this process (len(os.sched_getaffinity(0)), falling back to
os.cpu_count() where unavailable) instead of 2*cpu_count(). Measured on
this machine (sac + g1_motion_tracking + mujoco, num_envs=4096, 2000 iters,
steady-state second-half median): 86,162 -> 96,262 steps/s (+11.7%),
collector env_step 47.6ms -> 41.9ms. Explicit cpu_ids still fixes
nthread = len(cpu_ids).

* fix(logging): widen terminal reward name column by 10 chars

Reward component names in the Rich live table were truncated with an
ellipsis at 21 chars (e.g. "motion global root…"); widen the fixed
Rewards column to 31 so longer motion-tracking component names render
in full.

* docs(backends): add IsaacGym runtime setup script and guide

IsaacGym (Preview 4, EOL) only supports Python 3.6-3.8 and cannot live in
the main uv environment. Add an idempotent installer that builds a
self-contained external runtime under UNILAB_ISAACGYM_HOME (dedicated
miniconda + py3.8 hsgym env + user-provided Preview 4 tarball), aligned
with the UNILAB_BENCHMARK_HSGYM_* env vars used by the existing physics
benchmark. Add bilingual backend docs covering automated setup, benchmark
verification, manual fallback steps, and troubleshooting.

Refs #1332 #1334

* feat(backend): add IsaacGym subprocess backend

IsaacGym Preview 4 (EOL) only supports Python 3.6-3.8, so the backend runs
physics in a dedicated worker subprocess: the main-process IsaacGymBackend
keeps the numpy SimBackend surface, exchanges ctrl/state through shared
memory slots, and drives the worker over a length-prefixed pipe protocol
(INIT/ATTACH_SLOTS/STEP/SET_STATE/REFRESH/GET_META/SHUTDOWN). The worker is
Python 3.8 compatible, imports no unilab code, and converts IsaacGym xyzw /
world-frame conventions to the repo's wxyz contract at the boundary.

Runtime discovery uses UNILAB_ISAACGYM_PYTHON / UNILAB_ISAACGYM_HOME and
points at scripts/tools/setup_isaacgym_env.sh when missing. Sensor names
are mapped to tensor-API quantities at init (gyro, velocimeter, framequat,
framepos, contact-found); unmappable sensors and optional capabilities
(DR, render, playback) fail closed. Hot-path getters read shared memory
views with zero IPC round trips. Tests run against a mock worker speaking
the same protocol; real-runtime conformance cases skip without the Python
3.8 runtime.

Refs #1332 #1336

* feat(backend): map framezaxis sensors with exact site frames

g1's upvector sensors are framezaxis on sites (imu_in_pelvis,
imu_in_torso, left/right_foot), and g1_walk_flat's policy obs depends on
torso_upvector. Sites are rigidly attached to bodies, so the cold-path
scan now records each site's local pos/quat and the hot path composes it
with the body quaternion from the shared-memory body_state slot - exact,
with no extra IPC. Sites declaring euler/axisangle/xyaxes/zaxis
orientation fail closed, as do non-body/site objtypes.

Refs #1332 #1336

* feat(config): onboard g1_walk_flat to the isaacgym backend

Register G1WalkFlat for sim_backend=isaacgym and add isaacgym owner YAMLs
for the ppo/appo/sac/td3/flashsac trees with DENYLIST parity against the
mujoco owner (verified zero-diff via the generalized audit script, which
now covers the mujoco/isaacgym pair). PD-gain randomization is disabled
(effort-mode dofs) and playback is disabled at the config level until the
backend grows a playback plan. Support matrix gains the IsaacGym column
with Configured-level entries (no real-runtime validation yet).

Refs #1332 #1337

* docs(backends): refresh isaacgym page status after backend landing

Refs #1332 #1337

* cleanup: drop never-runnable G1 deploy scripts, bind docs to task owners

scripts/deploy/ (6 files, 1715 LOC) hard-coded owner-derived values instead
of reading them through Hydra, violating Config first / Fix at owner layer.
All defects below were reproduced on this branch's head:

- sim_prototype.py never ran since 0e534e13: it looks up MuJoCo sensor
  "gyro", but robots/g1/ only ever exposed pelvis_gyro / torso_gyro (true at
  the introducing commit too, and still true here). The documented
  pre-bringup obs validation therefore never executed once.
- Docs bound the exporter to a task owner whose actor obs width and action
  scale both disagree with the 2.0 / 514 the exporter emits. The ONNX width
  check catches the obs mismatch but the action scale would be silently off.
- --enable-zero-anchor-pos / --enable-zero-linvel used
  action="store_true" with default=True, so neither could ever be False,
  leaving two layout branches and two segment aliases unreachable.
- sim_prototype used the XML timestep (substeps=3) while the owner declares
  sim_dt 0.005 (substeps=4), so the "same config" check stepped at a
  different rate.
- export_deploy_config.py rejected the registered 23-dof owners
  (Expected 29 actuators, got 23).
- warmup/cooldown/motion_primitives (688 LOC) plus 7 emitted config fields
  had zero in-repo consumers.

test_obs_alignment_g1_wbt.py keeps the contract it actually guards: the
training-side per-term history and term-major assembly is now compared
directly against per-term deque semantics, dropping the importlib hop
through sim_prototype.ObsAssembler. The H=1 case proves plain layout-order
concat on its own.

Deployment docs (zh/en symmetric) now derive the hardware contract from the
Manager-Based task owner YAML and its scene XML: actor width is the sum of
dim * history_length over env.observations.actor.terms, and the action scale
comes from env.actions.joint_pos.scale, whose scalar vs regex-map form must
be reproduced exactly - no averaging, single-entry pick, or silent
broadcast. The 514 / 154 widths and the scale 2.0 quoted there were read off
the composed configs on this branch. Existing eval/train command examples
are left as they were; only claims about the removed tooling changed.

Training side untouched: no conf/ or src/ changes.

Validation on this head: ruff format --check (543 files) and ruff check
clean, mypy 248 files clean, pyright 0 errors, benchmark smoke 35/36 +
36/37 (1 platform-optional skip), Sphinx -n build succeeds with zero
warnings, full-repo sweep for the removed paths returns zero hits.
pytest -m "not slow": 2305 passed, 29 skipped, 1 xfailed at 74.89%
coverage, 4 failed - all 4 (go2w manager runtime, t800 registry metadata,
go2 terrain spawn, legacy env package closeout) reproduce identically on the
pristine base and are unrelated to these files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(reset-state): replace np.unique duplicate check with bincount

The reset-state transaction re-validates env_ids on every write (~6 calls
per env step, including one full-batch 4096-id begin per step). np.unique
sorts; bincount on the already range-checked ids is semantically identical
and ~30x faster at 4096 ids (~145us -> ~5us), ~4x at typical 138-row
partial resets.

Issue #1352 (roadmap #1348).

* feat(isaacgym): auto-download the Preview 4 tarball in setup script

The NVIDIA download URL redirects to a signed URL without login, so the
installer fetches the tarball itself (curl -fL with gzip validation);
--tarball remains as the offline escape hatch. Docs updated accordingly.

Refs #1332 #1334

* feat(config): drop appo/td3/flashsac isaacgym owners for g1_walk_flat

Keep the isaacgym onboarding surface at ppo + sac per maintainer decision;
unowned algo trees stay at REGISTERED level in the support matrix.

Refs #1332 #1337

* perf(motion): defer row-wise metrics to reset(), dedup reset motion gather

MotionCommand._update_metrics ran one Numba kernel over all rows every
step, but the 13 row-wise error metrics are consumed only by
CommandTerm.reset (episode log extras; reward/termination terms read
command buffers directly). The per-step path now skips the kernel;
reset() refreshes exactly the rows it reads from the same post-step
buffers, keeping consumed values bit-identical (~0.36ms/step at 4096
envs on Ryzen 9 9950X3D).

The reset path also gathered the resampled motion frames twice
(_resample_command, then _refresh_motion from _update_command; three
times for BoxMotionCommand). _resample_command now ingests the gathered
rows into the reference buffers via _ingest_motion_rows, and the
reset-path _update_command reuses them (~0.14ms/step at 4096 envs).

Issue #1355 (roadmap #1348).

* perf(obs): draw reset-path observation noise for reset rows only

The row-scoped reset path in ObservationManager.compute_group previously
drew noise with full-batch shapes so the shared RNG stream matched the
full-batch implementation exactly. That contract cost a full-batch RNG
draw + transform per noised term per reset (~0.7ms/step at 4096 envs for
g1_motion_tracking, where ~3.4% of rows reset each step).

Issue #1348 removed the RNG-stream parity requirement globally. The reset
path now slices term outputs to the reset rows before noise application,
drawing (n_reset, dim) instead of (num_envs, dim). Term funcs still run
full-batch (their (num_envs, ...) return contract is unchanged); reset
rows' un-noised values stay bit-identical to the full-batch compute, and
the per-step path is untouched.

Issue #1349 (roadmap #1348).

* style: apply ruff format to test_observation_partial_reset.py

Formatting left behind by the #1358 merge (make test-all's ruff format
rewrote the file after the branch head was committed).

* perf(noise): draw float32 noise directly instead of float64+astype

rng.random/standard_normal with dtype=np.float32 skips the float64
generation and the astype pass: ~2x faster draws (643us -> 338us for a
(4096, 64) block on Ryzen 9 9950X3D). The original slice plan of fusing
per-term draws into one block draw was measured and rejected — draw cost
scales with element count, not call count (fused 996us vs per-term 895us
for the g1 actor group), so only the dtype fast path is kept.

Bit-level noise values differ from the float64 path; the RNG-stream
parity requirement was removed globally in #1348. Noise distribution,
range, and in-place op order are unchanged.

Issue #1350 (roadmap #1348).

* perf(obs): share identical term computations across observation groups

Observation groups frequently repeat terms verbatim (e.g. g1_motion_tracking
reuses eight actor terms in the critic group via YAML anchors). Term func
outputs are never mutated in place by the per-term pipeline (noise/clip/scale
operate on fresh arrays; temporal buffers copy), so within one compute() call
the raw outputs of terms with identical func+params are interchangeable.

The manager now builds a per-group share key map on the cold path (plain
function terms with hashable params only; class terms are excluded) and
reuses the first group's raw result for later groups in the same compute()
call, saving one full-batch evaluation per duplicated term per step
(~0.5ms/step at 4096 envs for g1_motion_tracking).

Issue #1351 (roadmap #1348).

* docs(motion): document the _ingest_motion_rows subclass contract

Clarify on _refresh_motion and _resample_command that reset-path row
refreshes may be served by _ingest_motion_rows from _resample_command
(issue #1355), so MotionCommand subclasses overriding _refresh_motion
must mirror additions in _ingest_motion_rows (as BoxMotionCommand does).
Comment-only; no behavior change.

* fix(isaacgym): real-runtime fixes from first end-to-end bring-up

- worker: import isaacgym before torch (enforced by gymapi), and keep a
  private fd for the protocol pipe while rerouting fd 1 to stderr so the
  native banners cannot corrupt framed messages
- worker/backend: apply the scene keyframe as the initial state at INIT
  (name-mapped dof positions, wxyz->xyzw root), so get_default_dof_pos
  matches the post-INIT state as the conformance contract requires
- dependencies: prepend the hsgym env bin/ to PATH so ninja is reachable
  for the one-time gymtorch JIT compile; drop the stale manual-tarball hint
- setup script: self-check imports gymtorch too, pre-warming the JIT
  compile at install time instead of inside the first INIT handshake

Real-runtime conformance now passes: pytest tests/base -m slow -k isaacgym
-> 4 passed.

Refs #1332 #1336

* fix(isaacgym): lazy scene metadata and lazy materialize for env construction

The env constructor reads keyframe/default-qpos metadata before
materialize() (resolve_scene_default_qpos), so XML-scanned metadata is now
loaded lazily without the worker, and the worker handshake itself is
triggered lazily on first state access. Worker body/dof names are validated
against the MJCF document order at INIT (fail-closed on mismatch).

Real-machine: SAC g1_walk_flat/isaacgym runs 3/3 iterations to completion.

Refs #1332 #1336

* feat(cli): expose isaacgym via --sim and document the training guide

- cli: add isaacgym to SUPPORTED_SIMS with a runtime-availability check
  pointing at scripts/tools/setup_isaacgym_env.sh
- docs: bilingual training section with canonical CLI examples (train for
  sac/ppo, common Hydra overrides, GPU selection) and an honest sim2sim
  note: checkpoints transfer across backends, while eval playback is not
  implemented yet (play_render_mode: none skips playback at script level)

Verified on the real runtime: uv run train --algo sac --task g1_walk_flat
--sim isaacgym completes and writes checkpoints.

Refs #1332

* fix(isaacgym): reproduce MJCF position actuators and disable self-collision

Two real-runtime bugs made SAC on g1_walk_flat/isaacgym unable to learn
(reward stuck at -3.5, episode length ~14):

- ctrl semantics: the worker applied ctrl as raw torques via
  DOF_MODE_EFFORT, but SimBackend.step(ctrl) carries backend-native
  actuator input and the G1 MJCF uses <position kp kv forcerange>
  actuators, so position targets were injected as ~1 Nm torques. The
  worker now uses DOF_MODE_POS with set_dof_position_target_tensor, and
  the PD gains/forcerange/armature/frictionloss are parsed from the MJCF
  on the cold path (sensors.py, with MJCF default-class resolution) and
  pushed to the worker by name at INIT, because IsaacGym's importer drops
  kv, frictionloss, and joint ranges. get_actuator_gains()/
  get_actuator_ctrl_range()/get_joint_range() now answer from the XML.
- self-collision: create_actor passed collision filter=0 (on), but the
  G1 collision capsules overlap at the default pose (MuJoCo excludes
  those pairs via <contact><exclude>), generating permanent contact
  forces that railed the wrist/hip joints. Disabled via filter=1, the
  ecosystem-standard superset approximation.

Validated on hardware (RTX 4090): SAC g1_walk_flat isaacgym reaches
final mean reward 248 / episode length 1000 at 10.2M steps, tracking the
mujoco baseline (244-288, 934-1000). Adds a slow hold-pose regression
test and promotes SAC g1_walk_flat isaacgym to Tested in the support
matrix.

* feat(isaacgym): native viewer/camera rendering for eval playback

Implements the SimBackend play contract on the isaacgym subprocess
backend so policy eval no longer skips playback:

- worker: the sim is created with a graphics context whenever it runs on
  a GPU device (no window until create_viewer), enabling both the
  interactive gym viewer and headless camera-sensor capture; new
  INIT_RENDERER/RENDER_FRAME/CAPTURE_FRAME protocol commands, with
  viewer-closed detection surfaced as RenderClosedError on the host and
  a spherical tracking camera bound to env 0's root.
- host: get_play_capabilities (interactive renderer + native video
  capture), resolve_play_render_plan (auto = interactive when a display
  is reachable, record otherwise), init_renderer/render/
  capture_video_frame IPC forwarding, and run_playback via a new
  playback.py mirroring the motrix interactive/record loops.
- configs: drop play_render_mode=none from the g1_walk_flat isaacgym
  owners so train flows straight into eval playback.
- tests: protocol-level coverage through the mock worker (plan
  resolution, viewer lifecycle, capture roundtrip, record video writing,
  graceful window close) plus a real-runtime slow test for camera
  capture; owner contract test updated for auto mode.
- docs: isaacgym guide (en/zh) documents eval/playback usage and the
  GPU-graphics requirement; support matrix note regenerated.

Validated on hardware: uv run eval --render-mode record writes a correct
play_video.mp4 from a trained SAC checkpoint, the interactive viewer
renders live frames, and a short uv run train run chains into record
playback automatically.

Refs #1365

* research(isaacsim): record feasibility contract matrix

* refactor: extract shared locomotion sensor reward terms (#1371)

- common/sensor_reward_terms.py owns the named-sensor reward equations
  (track_lin_vel/track_ang_vel/lin_vel_z/ang_vel_xy/orientation) once, with
  the robot's sensor name bound via cfg params instead of family subclasses.
- common/sensor_terms.py converges to the pure SensorTermBase cold-path
  binding contract; file name and contents now match.
- common/manager_terms.py gains the shared unconditional alive term.
- randomize_encoder_bias moves to unilab.envs.mdp.events; the motion_tracking
  g1 module keeps a compatibility alias.

Refs #1371

* refactor: migrate g1/t800 owners onto shared sensor reward terms (#1371)

The five named-sensor reward equations and the unconditional alive term now
have a single owner in tasks/locomotion/common; g1/manager_terms.py drops its
copies and keeps only G1-specific terms (gait, pose, penalty, command,
curriculum). All g1_walk_flat and t800_walk_flat owners (ppo/appo/sac/td3/
flashsac) bind their robot's sensors via params in their owner YAMLs.
base_height switches to the identical common.manager_terms.base_height_l2
equation. t800 keeps its g1-owned gait/command/curriculum references; that
shared-biped extraction is left to a follow-up.

Refs #1371

* refactor: drop the motion_tracking encoder-bias re-export (#1371)

The two wbt_obs owners reference unilab.envs.mdp.randomize_encoder_bias
directly; the motion_tracking g1 module no longer re-exports the mdp event.

* refactor: fold SensorTermBase into common.manager_terms (#1371)

common/sensor_terms.py held only the binding base class and no actual terms,
so the module name and contents disagreed. SensorTermBase is generic
manager-term infrastructure (its consumers include a termination term), so it
moves to common/manager_terms.py next to the other shared term bases and
plain state/action terms; sensor_reward_terms.py keeps only the sensor reward
equations. g1/t800/sensor_reward_terms imports follow.

* refactor: share the command-resolution helper in locomotion common (#1371)

sensor_reward_terms no longer carries a second _command copy with a divergent
error message; it uses the common manager_terms helper (the codebase's
fail-closed 'capability unavailable' idiom).

* feat: port MicroDuck velocity task to Manager-Based API

Adapt #1368 to the #1042 runtime and keep the 47 STL assets in Hugging Face instead of UniLab Git history.

* refactor: migrate MicroDuck onto shared locomotion terms (#1371)

- Owner YAML now wires velocity tracking, lin_vel_z/ang_vel_xy/orientation
  from common.sensor_reward_terms with explicit sensor_name params
  (local_linvel/gyro/upvector), alive/base_height_l2 from
  common.manager_terms, and randomize_encoder_bias from unilab.envs.mdp.
- microduck/manager_terms.py drops the duplicated generic terms and keeps
  only MicroDuck-specific semantics; SensorTermBase comes from
  common.manager_terms after the common/sensor_terms.py fold.

* research(genesis): SimBackend 契约可行性探针与 go/no-go 报告 (#1372)

- probe_contract.py: 冷路径元数据/状态布局/控制/set_state/keyframe/全局 option/传感器/DR 对表 mujoco ground truth
- probe_runtime.py: init/destroy 生命周期、双 Scene、宿主 torch 副作用、离屏渲染
- probe_perf.py: 256/2048/4096 envs 下 NumPy 边界 D2H/H2D 成本
- REPORT.md: 能力矩阵(实测/源码推断/未验证三档证据)、A/B 对比、Child 2 映射决策;结论 go(方案 A 进程内薄 adapter,附四个前置条件)

探针复现: uv run --with genesis-world==1.3.3 python scripts/tools/genesis_feasibility/<probe>.py(三脚本 exit=0 实测)

* chore(deps): torch 窗口升级至 2.8(#1376)

genesis-world 1.3.3 声明不支持 torch<2.8,且 Child 1(#1372)实测
观测到传感器组合在 torch 2.7 下崩溃一例。maintainer 决策升级窗口。

- pyproject: torch==2.7.0 -> torch==2.8.0(非 aarch64 行;aarch64 已为 2.9.0)
- uv.lock 重生成(torch 2.8.0+cu128,triton 3.3->3.4,nccl/nvjitlink 联动更新)

* test(scripts): 锁文件守卫同步 torch 2.8.0+cu128 期望(#1376)

* feat(backend): GenesisBackend contract adapter 实现(#1378)

- src/unilab/base/backend/genesis/:dependencies(lazy optional import +
  genesis-world==1.3.3 钉扎 + GenesisDependencyError)、materialization
  (mujoco 冷路径扫描 keyframe/sensor/actuator/全局 option、torch 全局
  快照恢复、一进程一 gs.init 会话守卫、fail-closed option 映射)、
  backend(link 寻址 root state、control_dofs_position + nsteps 循环
  实现 set_pre_step_control、step/reset 屏障单次 D2H host cache、
  传感器等价物、DR 只声明实测项、geom 名称等能力 fail-closed)
- factory/registry 接入:create_backend genesis 分支 + GENESIS_AVAILABLE;
  registry._SUPPORTED_SIM_BACKENDS 增加 genesis
- EnvCfg 新增 4 个 Optional genesis 字段(integrator/constraint_solver/
  friction_cone/solver_iterations,补偿 MJCF 全局 option 丢弃)
- pyproject 增加 genesis optional extra(不进基础安装);setuptools
  解除 <70 pin(quadrants==1.3.0 要求 >=77,仓库无运行时引用)
- 测试:fake runtime(镜像 1.3.3 API)17 项 mock 测试 + conformance
  genesis 参数化(无 runtime 清晰 skip)+ 真实 runtime slow lane
  (g1 scene_flat.xml acceptance smoke + re-init 守卫)

设计依据:scripts/tools/genesis_feasibility/REPORT.md §5 映射决策(#1372)

* feat: add SAC off-policy owner for the MicroDuck velocity task

The MicroDuck recipe has a single legacy baseline (PPO), so the SAC owner
keeps env/reward semantics identical to the on-policy owner; the mujoco leaf
carries only backend/algo identity (num_envs 2048, learning_starts 10,
updates_per_step 8, alpha_init 0.001).

Validated: 10-iter smoke (train -> checkpoint -> ONNX export max_diff 7.5e-08
-> play render); 2000-iter trend run shows reward/mean 0.22 -> 0.90 and
episode length 13 -> 48 at 4.1M env steps; test_config_system sweeps the new
owner automatically (163 passed).

* feat(config): g1_walk_flat 接入 genesis 后端与中英文文档(#1380)

- conf/ppo/task/g1_walk_flat/genesis.yaml:继承 base,DENYLIST 字段跨
  后端一致;genesis_integrator=implicitfast 补偿 MJCF 全局 option 丢弃;
  play_render_mode=none
- registry:G1WalkFlat 注册 genesis 变体;cli SUPPORTED_SIMS + runtime
  检查分支
- audit_sim2sim_contracts 增加 (mujoco, genesis) 对(VERDICT: TRANSFERABLE)
- support_matrix 增加 genesis 列(experimental/Configured,诚实标注
  unsupported 边界与 xfailed smoke)
- docs:5-genesis.md 中英文页面(安装/torch>=2.8/一进程一 gs.init/
  unsupported 清单),明确记录 materialize 生命周期缺口
- 测试:owner contract(ppo-genesis 用例 + slow lane 真实 runtime
  smoke,因 #1382 暂 strict xfail)、env config compose、CLI 路由、
  sim2sim resolver、audit/support matrix 断言

真实 runtime smoke 当前阻塞于 #1382(GenesisBackend materialize 生命
周期 + 体帧运动学),修复合入后去 xfail 复跑

* fix(backend): GenesisBackend materialize 幂等/lazy 化与体帧运动学(#1382)

- materialize() 改为幂等 + 首次状态访问 lazy 触发(isaacgym 模式),
  匹配 ManagerBasedRlEnv 先 EntityScene 校验后 materialize 的构造顺序;
  _require_running -> _require_state(13 处);保持一进程一 gs.init 守卫
- 实现 get_body_pos_b/get_body_quat_b,补 get_body_lin_vel_b/ang_vel_b
  的 materialize 守卫(此前 pre-materialize AttributeError);体帧量
  全部从既有 host cache 派生,热路径零新增 D2H;真实 runtime 对照
  mujoco 后端同姿态数值 atol=2e-3 一致(pelvis/hip/knee 三 body 四
  getter)
- mock:lifecycle 用例改幂等语义 + constructor lazy-build 断言
  (build_count 0->1->1)+ 体帧解析断言
- re-init 守卫测试经 _reset_session_state_for_tests 恢复会话标记,
  消除同 pytest 进程内对后续 genesis lane 的干扰

解锁 #1380 真实 runtime smoke(compose->构造->reset->12 步->play
none/record->cleanup 已在本分支等价验证)

* feat(config): 解锁 genesis owner 真实 runtime smoke 并同步文档(#1380)

- #1383(materialize 幂等/lazy + 体帧运动学)合入后去掉
  test_g1_walk_flat_genesis_owner_real_runtime_smoke 的 strict xfail:
  真机 slow lane 通过(18.26s,compose->构造->keyframe reset->12 步->
  play none/record->cleanup)
- 5-genesis.md 中英文:支持证据更新为含真机 smoke;lifecycle 段落改写
  为已修复事实(幂等/lazy materialize);unsupported 清单移除体帧运动学
- support_matrix.py genesis 注记同步(移除 xfail 描述与体帧条目),
  重新生成 support_matrix.md

* feat: add mjwarp backend for the MicroDuck SAC owner

- Register MicroduckVelocityFlat for the mjwarp backend and add the SAC
  mjwarp leaf: capacity knobs (nconmax 128 / njmax 256, render_spacing 2.0)
  and play_render_mode=record, matching the established mjwarp owner
  pattern; algo identity unchanged (num_envs 2048, learning_starts 10,
  updates_per_step 8, alpha_init 0.001, max_iterations 10000).
- Disable base_com (body_ipos reset payload) and push_robot (interval
  velocity push) in the mjwarp leaf: the backend advertises no
  reset-payload or interval DR capabilities yet. encoder_bias stays
  (host-side Entity data surface, backend-agnostic).
- SAC base tuning: alive weight 0.1 -> 10.0.
- Registry contract test now pins available_backends [mujoco, mjwarp];
  support matrix regenerated.

Validated: 10-iter mjwarp smoke (train -> checkpoint -> ONNX max_diff
7.5e-08 -> record playback); make test-all green (2404 passed).

* feat(config): g1_walk_flat SAC owner 接入 genesis 训练支持(#1386)

- conf/sac/task/g1_walk_flat/genesis.yaml:algo 块与 mujoco SAC owner
  逐项一致保 DENYLIST parity;genesis_integrator=implicitfast;
  play_render_mode=none;kp/kd reset 随机化保持启用(genesis 声明实测
  RESET_TERM_KP/KD DR,不同于 isaacgym owner 的 pd_gains: null)
- 测试:owner contract 追加 sac-genesis 用例;真实 runtime smoke 参数化
  ppo/sac 两树(子进程隔离满足一进程一 gs.init);compose/audit/
  support matrix 断言同步
- 支持矩阵 genesis 列追加 SAC cell(Configured);5-genesis.md 中英文
  更新为 PPO+SAC 并加 SAC canonical 命令

真机验证(RTX 4090 / torch 2.8.0+cu128 / genesis-world 1.3.3):
env smoke ppo+sac 2 passed;audit sac mujoco<->genesis TRANSFERABLE;
SAC 短训练回路 smoke(num_envs=64, 3 iterations)端到端通过,loss/熵
正常、checkpoint model_1/2/3.pt 落盘

* feat(backend): genesis eval 原生渲染打通(viewer + 离屏录制)(#1388)

- playback.py(新增):display_available(:0/wayland-0,
  isaacgym 语义)、MuJoCo 球坐标 camera pose 映射、run_genesis_playback
  (live scene 驱动:interactive 60Hz / record 逐帧 capture 写 mp4)
- backend.py:get_play_capabilities/resolve_play_render_plan(none/auto/
  interactive/record 四模式与 isaacgym 对齐,非法值 fail-closed)/
  init_renderer(post-build lazy 挂 viewer/camera,(headless,capture)
  pinning)/render(自初始化 + RenderClosedError 翻译 genesis 私有异常
  + is_alive 双保险)/capture_video_frame/run_playback;close() 停
  viewer;render 全在冷/playback 路径,step 热路径零依赖
- owner YAML(ppo+sac):play_render_mode none->auto
- 测试:fake runtime 增 camera/viewer/visualizer 面;8 个新 mock 测试;
  conformance 相机捕获用例;真机 slow lane record E2E + interactive
  三帧实测;owner 断言 auto
- 文档:5-genesis.md 中英文 playback/render 段落重写;支持矩阵同步

真机实测(RTX 4090 / DISPLAY=:0 / torch 2.8.0+cu128 / genesis-world
1.3.3):record eval 端到端产出 1280x720@50fps 视频;interactive
post-build viewer 开窗成功、3 帧渲染正常、关窗抛 RenderClosedError

* fix(runner): collector inference tick 超时配置化(#1391)

Genesis 在 materialize 冷路径编译内核(2048 envs 实测 33.8-36.3s,
无跨进程缓存),确定性超出 runner 硬编码的 30s tick-0 预算,完整
训练无法启动。

- DoubleBufferOffPolicyRunner: inference_request_timeout_sec 可选构造
  参数(默认仍 30.0,校验正数),tick 等待与 TimeoutError 消息用
  实例值;不改 lifecycle/同步协议
- fast_sac builder 从 cfg.training.inference_request_timeout_sec 透传
- conf/sac/config.yaml 记录默认 30.0;sac genesis owner 提至 180.0
- 测试:dispatch kwargs 断言 + sac genesis owner compose 断言(180.0)

* fix(backend): viewer 关闭异常在 step 边界翻译为 RenderClosedError(#1393)

Genesis 挂有 viewer 时 scene.step() 内部也会更新 viewer;viewer 关闭
时私有 GenesisException 从 backend.step 逃逸(#1388 只翻译了 render()
路径),真机完整训练的 play 阶段因此 exit 1。

- _raise_if_viewer_closed 统一翻译(render 与 step 共用):viewer 死亡
  时抛契约 RenderClosedError 并从 visualizer 分离死 viewer(保留
  viewer_lock,rasterizer destroy 需要;分离后物理可继续)
- _physics_substep 包装 scene.step 的翻译边界,step 双分支共用
- fake runtime 复刻真实行为:scene.step 更新挂载 viewer、viewer 死亡
  时 update 抛错;playback mock 断言同步为忠实计数
- 测试:mock test_step_translates_viewer_closed;真机 interactive 测试
  增补 step 关闭断言与分离后物理续跑(双 backend 共享会话,不 close)

真机验证:短训练 + auto play 端到端 exit 0(viewer 中途关闭时 INFO
'Render window closed.' 干净退出,原先 exit 1);slow 电池同进程
16 passed;mock 26 passed

* chore(docs): SAC g1_walk_flat genesis 支持等级 Configured → Tested

真机完整训练验证(2026-08-31,RTX 4090 / torch 2.8.0+cu128 /
genesis-world 1.3.3):uv run train --algo sac --task g1_walk_flat
--sim genesis 5000/5000 iterations 完成(reward/mean 6.5 → 244.8,
best 274.5;episode length → 987/1000,timeout_rate → 1.0;10.26M
env steps / 224s wall time;run 2026-08-31_23-04-01_genesis),加
model_5000.pt 的 record playback 步态视频验证——与 isaacgym SAC
Tested 的证据标准一致。

- support_matrix.py: _MAINTAINER_VALIDATED_GENESIS_ENTRYPOINT_TASKS
  加入 (sac_torch, g1_walk_flat) 并记录验证事实;genesis 注记更新
- 5-genesis.md 中英文:SAC Tested(含训练指标)/ PPO 保持 Configured
- 测试断言同步;support_matrix.md 重新生成

* fix(backend): 交互 viewer 相机姿态改用 Z-up 完整矩阵(#1396)

genesis 1.3.3 Viewer.set_camera_pose(pos, lookat) 复用 self._camera_up,
而该值在 Viewer 初始化时被改写为 ViewerOptions 默认相机位姿的相机系
y 轴(≈(-0.49,-0.07,0.87),非世界 Z-up),post-build 挂载后设置相机
被滚转约 30°(真机实测 trackball 矩阵与期望 max diff 0.50),地面
斜向上。离屏 camera 显式传 up=(0,0,1) 故正常。

- playback.camera_pose_matrix_z_up:adapter 自行用 Z-up 构建 4x4
  相机位姿;init_renderer 交互分支改走 pose= 分支(不读 _camera_up)
- mock:断言 viewer 收到 pose 矩阵且 x 轴水平(pose[2,0]==0)
- 真机 slow lane:读回 trackball pose 与期望矩阵 assert_allclose
  (atol=1e-5),防回归;fake 记录 set_camera_pose 全参数

* feat(isaacsim): add headless subprocess backend

Extract shared subprocess IPC primitives, wire IsaacSim 5.1/IsaacLab 2.3 runtime discovery, and register the G1 PPO/SAC owners with fail-closed rendering and randomization boundaries.

* feat: add generic stage-based curriculum engine (#1397)

Port mjlab's envs/mdp/curriculums.py to the NumPy manager runtime:
reward_curriculum ramps a reward term's weight/params and
termination_curriculum ramps a termination term's params/time_out from a
declarative stage table keyed on env.common_step_counter. The shared
_validate_stages/_apply_stages engine checks stage ordering, field
existence, and param-key spelling fail-closed at manager construction.

The manager protocol seam gains reward_manager/get_term_cfg access so the
generic terms can reach the live term cfgs without concrete-env imports.

A demo owner YAML (tests/fixtures/mjlab_cartpole/conf/stage_curriculum.yaml)
declares one ladder per public term, covered by materialization, fail-closed,
and real-runtime ramp tests; a 64-env/2-iteration PPO smoke through
train_rsl_rl.py verified the full training config chain and the
Curriculum/<term>/<field> log extras.

* feat: add generic IMU misalignment observation terms (#1400)

* feat: add shared locomotion reward/termination terms (#1398)

Port mjlab's generic locomotion terms into the shared layers:

- envs/mdp/rewards.py: joint_pos_limits, posture, variable_posture
  (per-joint std Gaussian kernels resolved from {regex: std} mappings).
- envs/mdp/terminations.py: nan_detection over the base-owned entity
  physics state instead of relying on the optional global nan guard.
- tasks/locomotion/common/gait_terms.py: window-form feet_air_time,
  feet_clearance, feet_swing_height, feet_slip and
  angular_momentum_penalty on the SensorTermBase cold-path binding;
  per-foot contact sensor groups mirror mjlab's ContactSensor
  primary/slot any-reduce, foot heights/velocities come from
  body_link_pos_w/body_link_lin_vel_w (exact on flat terrain).

conf/ppo/task/g1_walk_flat/base.yaml now references the shared
feet_air_time (weight 0.25, mjlab window 0.05-0.5 s, command-gated);
the 23-DoF rough owner disables it explicitly because its scene XML
declares no named foot contact sensors.

* feat: extend MBA event/DR terms (#1399)

- add apply_body_impulse step-mode class term with the pinned mjlab
  cooldown->trigger->sustain->expire lifecycle, eccentric force
  application via cross(offset_w, force), and per-backend fail-closed
  capability binding
- extend IntervalRandomizationPlan with body_angular_velocity_delta and
  body_torque; SimBackend.apply_body_force gains an optional torque
  channel (mujoco implements it via xfrc_applied, drake/motrix fail
  closed, mjwarp/isaacgym stay unsupported)
- push_by_setting_velocity now accepts world-frame angular velocity
  ranges; mujoco converts them into the free-root body-frame qvel
  columns using the in-state root orientation
- release min_step_count_between_reset>0 for reset-mode payload terms:
  the reset transaction re-supplies gated rows from the last committed
  per-env field values, preserving keep-current semantics densely
- document the extended interval plan fields in the DR user guide

* feat: add mjwarp per-world DR consumption (#1401)

Expand the declared DR model fields to (nworld, ...) on the cold path
before CUDA graph capture, consume ResetRandomizationPayload rows in
set_state with graded set_const/set_const_0 recomputation, and stage
interval pushes/body forces through data.xfrc_applied plus root velocity
kicks through the existing upload+forward barrier. Re-enable base_com and
push_robot on the microduck SAC mjwarp owner. Per-world gravity DR stays
fail-closed.

* fix: align mjwarp apply_body_force signature with SimBackend torque param (#1401)

* style: ruff format canonical form for #1401

* feat: add step-staged command/event curricula and privileged foot obs terms (#1402)

- envs/mdp/curriculums.py: generic command_curriculum / event_curriculum on
  the S1 stage engine (live term-cfg mutation keyed by common_step_counter);
  the shared stage engine now tolerates param-less command term cfgs.
- events.randomize_rigid_body_com and microduck UniformVectorCommand consume
  their live cfg ranges on every apply/resample so staged CoM-range and
  head-pose-range curricula take effect without rebuilding terms.
- tasks/locomotion/common/gait_terms.py: mjlab velocity critic terms
  foot_height / foot_air_time / foot_contact / foot_contact_forces
  (log1p-compressed forces, per-foot air-time tracking with reset).
- managers/_types.py: expose event_manager + command get_term_cfg on the
  manager-term protocol surface.

* feat: align MicroDuck velocity recipe with legacy via MBA capabilities (#1402)

Owner yaml (PPO + SAC bases kept in sync, alive weight excepted):
- five legacy step-staged curricula (stage step = legacy iter x 24, matching
  num_steps_per_env=24): action_rate -0.1->-1.0, head_pose_bias 0->+3.0,
  standing envs 0.02->0.25, head_pose ranges ->±1.1/±1.4 rad, trunk/head CoM
  ±3->±15/±10mm.
- rewards: window-form air_time (0.125, 0.300)s w=3.0, foot_clearance -2.0,
  foot_swing_height -0.25, foot_slip -0.1, dof_pos_limits -1.0,
  angular_momentum -0.02; angular tracking switches to the merged-omega form
  exp(-((cmd-wz)^2+wx^2+wy^2)/0.5) at w=2.0.
- commands: twist x ±0.4 / y ±0.3 / wz ±1.0, resample (3,8)s, turn-in-place
  15%, rel_forward_envs 0.2; terminations: tilt 70deg, explicit nan_state.
- observations: IMU 0-1 step random delay (period 64), joint_vel fixed 1-step
  delay; critic gains privileged foot terms to 76D (actor 61D untouched).
- DR: foot friction (0.7,1.3), armature x(0.9,1.1), head CoM, push interval
  (3,6)s; mjwarp owner consumes all of them through the S5 payload path.
- deploy_contract: MICRODUCK_CRITIC_OBS_DIM 64 -> 76.

* feat(isaacsim): add eval rendering modes

* fix:Overexposed scene

* fix:fixed camera of interactive model

* fix:floor

* feat(assets): host six robots' meshes/textures on HF and slim the wheel (#1422)

Move g1/go2/a2/allegro_hand/sharpa_wave/go2_arm binary assets (~130 MiB)
out of git onto the existing unilabsim/unilab-robots HF dataset repo:

- hub.py: ROBOT_ASSET_SPECS registry for all HF-hosted robots plus
  ensure_robot_assets_for_paths(); go2_arm/go2w also resolve the shared
  ../go2/assets mesh dir their XMLs reference.
- create_backend() resolves registered robot dirs on the cold path before
  any backend parses XML; config_adapter does the same for play_profile
  scene materialization.
- unilab-pull-assets covers every registered robot and gains '--robot all'.
- pyproject.toml source-exclude keeps the HF-hosted directories out of the
  wheel/sdist (wheel: 55.8 MB -> 12.1 MB); .gitignore ignores the download
  locations; files land back at their original paths so XML meshdir
  references keep working.
- Docs (en/zh) and AGENTS.md updated for the new convention.

* feat: move conf/ and training entrypoints into the unilab package (#1421)

Relocate conf/ to src/unilab/conf/ and the five training entrypoints
(plus train_offpolicy.py and play_interactive.py) to src/unilab/scripts/
so a built wheel is self-contained: train/eval/demo work from any
directory after pip install.

- cli.py / demo.py / cli_completion.py / ipc/dp_launcher.py /
  algos/hora/distill_config.py: replace repo-root assumptions with
  package-resource location; run artifacts (logs/, checkpoints) now
  anchor at the caller's CWD
- error hints switch to pip context (pip install unilab[<extra>])
- pyproject.toml (+rocm): uv_build>=0.12.1,<0.13, authors/keywords/
  classifiers; __version__ reads importlib.metadata (single source)
- top-level dev scripts, tests, docs, CI metadata updated to the new
  paths

* docs: record unisim extraction boundary ADR (#1430)

* feat: connect UniLab consumers to unisim-core (#1433)

* docs: document unisim-core migration (#1434)

* feat: connect UniLab consumers to unisim-core

* docs: document unisim-core migration

* fix: support macOS affinity and cache CI assets

* chore: consume unisim-core 0.1.5

* feat(drake): document and package Drake backend

* feat(drake): add standalone setup path

* feat: remove in-tree physics backend implementations

* docs: finalize unisim-core migration guidance

* style: normalize Drake test imports

* chore: consume unisim-core 0.1.11

* docs: clarify Drake installation and training

* chore: consume unisim-core 0.1.12

* fix(cli): expose Drake simulation route

* test: remove local unisim checkout dependency

* docs: reorganize backend documentation

* fix: preserve interactive viewer camera controls

* feat: route MuJoCo interactive eval to viewer script

* feat(drake): render Drake physics with native MuJoCo

* fix: enable policy actions in interactive eval

* feat(drake): support macOS batch training

* build: use released unisim-core from PyPI

* fix(drake): detect system Eigen headers

* fix(test): make MuJoCo affinity test hermetic

* fix(assets): compact asset download output

* fix(assets): print per-robot pull progress

* test: remove obsolete audit scaffolding

* style: normalize cleaned test modules

* ci: lint test modules for dead names

* test: name registry policy by current contract

* feat: complete minimal manager-based microduck slice

* fix(test): assert removed backend module path

* refactor(mba): keep task semantics out of shared terms

* refactor(mba): keep scalar metric in shared catalog

* feat(microduck): baseline alignment contract and controlled comparison facilities

Issue #1453 (child 1/5 of #1452): declarative owner-layer contract table
against pollen-robotics/microduck_rl @ 29e887ec, read-only audit script,
drift-guard test, and zero-action statistical rollout script for the three
MicroDuck tasks (ppo tree, mjwarp owner).

* fix(assets): aggregate pull summary and silence HF download logs

* fix(assets): drop Namespace deletion; apply ruff format

* feat(microduck): align physics layer with upstream recipe (issue #1454)

- scene_flat.xml <option>: implicitfast/Newton/pyramidal, iterations 10,
  ls_iterations 20, tolerance 1e-8, ls_tolerance 0.01 (upstream mjlab SimCfg
  injection values; timestep stays backend-owned via sim_dt)
- ppo microduck base: sim_dt 0.005 (substeps 4), push_robot per-env interval,
  terminations drop base_height (time_out + tilt 70 deg + nan_detection)
- reset_base event: reset_root_state_uniform xy +-0.5 m, yaw +-pi,
  z +[0, 0.01] on keyframe z=0.12, zero velocities, joints exactly HOME
- randomize_body_mass_inertia event: alpha-only pseudo_inertia slice scaling
  trunk_base mass+inertia by one shared log-uniform factor in [0.95, 1.05],
  sampled once per env and replayed every reset (upstream startup semantics);
  ResetStateTransaction/Entity gain body_inertia bind/write with a
  caller-compiled, mass-cross-validated default table
- alignment_contract: flip physics/solver/events/terminations entries to
  match; tests updated and extended

* feat(microduck): align velocity reward stack with upstream HEAD (issue #1455)

* feat(microduck): align training infra with upstream recipe (issue #1456)

- thread env-level RNG seed through the three microduck mjwarp owners
  (env.seed=42): the Hydra -> BackendAdapter -> registry chain already
  reaches ManagerBasedRlEnvCfg.seed, so command/noise/delay/DR sampling is
  now reproducible across runs; unset owners keep the None default
- fix init_at_random_ep_len: RslRlVecEnvWrapper.episode_length_buf becomes
  a property whose setter propagates into the env's real episode counters
  via the new ManagerBasedRlEnv.set_episode_length_buf cold-path entry
  (keeps episode_length_buf and state.info["steps"] in sync); upstream
  mjlab's wrapper setter writes the env buffer directly, so staggering is
  effective upstream and now matches here
- scale alignment: algo.num_envs 2048 -> 4096 and algo.seed 1 -> 42 in all
  three mjwarp owners; max_iterations 500 -> 2000 (2000 x 24 = 48000 env
  steps, exactly covering every curriculum terminal stage; final budget
  remains a child 5 decision)
- flip infra.num_envs / infra.seed contract entries to match and add an
  infra.env_seed match entry guarding the new YAML field

* feat(scripts): add microduck UniLab vs upstream PPO metrics comparison

Parse rsl_rl tfevents from multiple run dirs per side, aggregate
final-window stats and convergence speed across seeds, and emit a
markdown comparison report plus JSON curve dump. Handles aliased
reward/termination term names between the two codebases, skips
aborted runs with a warning, and degrades gracefully to single-seed
statistics.

Issue #1457 (roadmap #1452 child 5/5).

* docs(microduck): comparison benchmark report against upstream microduck_rl (issue #1457)

* fix: bump tensorboard to >=2.21.0 to drop pkg_resources dependency

setuptools>=82 removed pkg_resources; tensorboard 2.20 imports it at
startup, breaking 'uv run tensorboard'. tensorboard 2.21 switched to
importlib.metadata. The rocm variant pins setuptools<70 and is
unaffected.

* docs(microduck): move comparison report out of repo to PR comment (issue #1457)

The alignment report is experiment evidence, not a maintained artifact;
publish it as a comment on the integration PR instead of tracking it in git.

* feat(cli): route mjwarp interactive eval through the MuJoCo viewer

eval --sim mjwarp --render-mode interactive now launches play_interactive.py
with mjwarp owning the physics rollout and MuJoCo rendering env[0] (forced
single env, same contract Drake already uses). play_interactive binds the
CUDA Warp process device before env construction, matching the offpolicy
train entrypoint. Record playback routing and owner defaults are unchanged;
support matrix prose and mjwarp owner YAML comments updated to match.

* feat(cli): fall back to sibling owner config for eval without backend owner YAML

Eval replays a trained checkpoint, so a missing owner YAML for the
requested backend no longer aborts the route: the CLI reuses a sibling
backend owner of the same task (same profile shape) and re-applies the
requested backend via the sim2sim-allowlisted training.sim_backend
override. The runtime sim2sim preflight still validates the composed
config against the source run contract. Train keeps requiring the owner
YAML to exist.

* fix(locomotion): gate foot contact on the contact-frame normal column (issue #1468)

MuJoCo <contact data="force"> sensors report the force in the contact
frame whose first axis is the contact normal, but the shared locomotion
contact terms gated on column 2 (a tangential component). Every width-3
contact sensor in the repo (microduck, go1, go2, a2) is a contact-frame
force sensor, so the convention is wrong for all current users: standing
microduck reads 96.5% false 'air' time, corrupting feet_air_time /
feet_swing_height / feet_slip rewards and the foot_contact /
foot_air_time observation channels.

Gate on column 0 for both 1-D found and 3-D force sensors in
gait_terms._FootContactTerm, manager_terms.feet_phase_contact and
manager_terms.feet_air_while_standing; pin the convention with
tangential-distractor fixtures and a regression test.

Empirical check (64 envs, mjwarp, seed 42): term gating vs normal-force
ground truth agreement 1.000 standing / 0.998 walking (was 0.052/0.741).

* fix(go2): disable drake contact reward until drake reports contact-frame force (issue #1471)

Cross-backend audit of the #1468 column convention: mujoco and mjwarp pass
raw sensordata through (contact frame, column 0 = normal), motrix forwards
the native contact sensor with the same layout (verified on go1 standing:
~31 N normal in column 0 per foot), genesis/isaacgym/isaacsim fail closed
for 3-D force sensors. drake_uni instead synthesizes per-body net contact
force in the WORLD frame, so the normal component lands in column 2 there;
the go1 drake owner already disabled the contact reward, and this does the
same for go2 ppo/sac drake owners until the drake adapter reports
contact-frame force.

Also document that reduce="netforce" contact sensors report world-frame
force and sit outside the gating contract.

* test(go2): pin drake contact reward as disabled in train-script config test (#1471)

* fix(tools): repair stale install prefix when ISAACGYM/ISAACSIM_HOME was relocated

Conda envs and venvs are not relocatable: entry-point shebangs, activate
scripts, .pth/egg-link files, PEP 660 editable finders and conda-meta all
hard-code the install prefix. After the local cache was moved from the legacy
$HOME/.unilab/{isaacgym,isaacsim} to the current ~/.cache/unisim default,
every skip-check/marker still passed but pip could not execute and the
editable installs vanished from sys.path, breaking resumable installs.

Both setup scripts now detect the stale prefix from the pip shebang and
rewrite it in place (shebang lines, activate scripts, editable finders,
.pth/egg-link, conda-meta), use python -m pip instead of the bin/pip entry
point, and verify isaacgym/isaaclab by import rather than trusting pip show
or a stale marker (isaacsim 06_verify is forced to re-run after a repair).

* feat(microduck): port BAM xl330-m6 voltage actuator model (issue #1474)

NumPy port of the upstream bam.mjlab.BamActuator (xl330-m6 voltage servo)
as a task-owned action term on the Manager-Based runtime, recomputing
motor torque every physics substep through the SimBackend
set_pre_step_control contract (go2w precedent): LIFO command-delay ring
buffer (lag 3-6 substeps), per-env battery model (vin 6.5-8.2 V startup
DR with load-dependent drop), firmware P law with current-limit duty
window and PWM clip, back-EMF torque equation, and the m6 friction
budget (Coulomb + Stribeck + directional load + quadratic) under per-env
friction_scale reset DR.

Approximation boundaries vs upstream (documented in the module
docstring): the friction budget is folded into the output torque with a
torque-domain stiction clip because the SimBackend contract has no
per-substep dof_frictionloss/dof_damping write channel, and the external
torque is a finite-difference estimate since qfrc_* is not exposed.

New task MicroduckVelocityBamFlat (mujoco owner only; the mjwarp
host_numpy profile rejects pre-step control) reuses the PD recipe's
reward/obs/curriculum verbatim so the actuator model is the only delta.

Probe cross-check vs upstream (64 envs, seed 42): zero-action falls
356/64 envs with mean first fall at 64.1 steps (upstream 383, 64),
knee-step t90 403 ms (upstream ~370 ms), steady-state error overall
0.036 rad / knee 0.118 rad (upstream 0.049 / 0.148; stiffer by ~25% from
the torque-domain friction approximation).

* docs: regenerate support matrix for MicroduckVelocityBamFlat (#1474)

* feat: split RL algorithm layer into standalone uni_rl package (roadmap #1476) (#1484)

* feat: consume uni-rl package and remove migrated algorithm layer (issue #1480)

- Depend on published unilab-rl==0.1.0a2 (TestPyPI index) in both
  pyproject.toml and pyproject.rocm.toml; regenerate uv.lock and
  uv.rocm.lock.
- Delete the migrated algorithm layer: src/unilab/algos/, src/unilab/ipc/,
  src/unilab/logging/, and migrated utils/observation helpers, plus their
  tests (119 files).
- Inject env construction into uni_rl runners via the new picklable
  unilab.base.env_factory (registry_env_factory / make_registry_env) and
  bind_backend_process_device in unilab.base.process_device; train_appo and
  train_offpolicy pass env_factory to the uni_rl runner builders.
- Re-home UniLab-owned HORA pieces: play_hora_appo (scripts/) keeps the
  play_fn resolver and sim2sim validation; hora_distill_config (training/)
  keeps teacher-default composition. Callers of uni_rl
  cfg_with_checkpoint_runtime now apply teacher defaults first, matching the
  new uni_rl caller contract.
- Point Hydra class_name/resolver strings and structured config defaults at
  uni_rl.* classes; the HORA APPO resolver stays UniLab-side.
- Workaround: uni_rl 0.1.0a2's double-buffer builder helpers do not forward
  backend_device_binder to DoubleBufferOffPolicyRunner, so train_offpolicy
  sets runner.backend_device_binder post-construction (mjwarp collector path
  only) until uni_rl grows the builder kwarg.
- Update AGENTS.md and sphinx docs to reference the uni_rl package; kept
  api_reference stub pages pointing at the new home.
- Rewrite the architecture guard tests to assert the migrated layers are
  gone from UniLab and not re-defined.

Validation: make test-all green at this head (ruff format/check, mypy 156
files, pyright 0 errors, pytest 2234 passed / 20 skipped / 319 deselected /
1 xfailed, benchmark smoke 36/37 with 1 platform-optional mlx skip).

* build: pin uni-rl 0.1.0 from TestPyPI (issue #1481) (#1483)

- pyproject.toml + pyproject.rocm.toml: unilab-rl==0.1.0a2 -> 0.1.0;
  uv.lock and uv.rocm.lock regenerated (rocm via Makefile sync-rocm swap).
- train_offpolicy.py: drop the post-construction backend_device_binder
  attribute workaround; the kwarg is now forwarded by uni_rl 0.1.0's
  build_*_double_buffer_runner helpers (unilabsim/uni_rl#3).

Validation: uv run pytest -m "not slow" 2234 passed / 20 skipped /
319 deselected / 1 xfailed.

* feat: adopt unilab-rl 0.2.0 algos layout (issue #1485) (#1486)

* feat: adopt unilab-rl 0.2.0 algos layout (issue #1485)

- pyproject.toml + pyproject.rocm.toml: unilab-rl==0.1.0 -> 0.2.0;
  uv.lock / uv.rocm.lock regenerated (rocm via Makefile sync-rocm swap).
- Rewrite uni_rl.{appo,common,fast_sac,fast_td3,flash_sac,him_ppo,hora,
  rsl_rl*} -> uni_rl.algos.<same> across src imports, repo-root scripts,
  tests, conf/**/*.yaml class_name/runtime_resolver strings, and
  structured_configs.py defaults. uni_rl.{ipc,logging,offpolicy,utils,
  env_contract} references unchanged (runtime infra stays top-level in
  unilab-rl 0.2.0).
- AGENTS.md + docs/sphinx: repo references renamed unilabsim/uni_rl ->
  unilabsim/unilab-rl; algo module paths updated to uni_rl.algos.*.

Validation: uv run pytest -m "not slow" 2234 passed / 20 skipped /
319 deselected / 1 xfailed.

* style: ruff format/import-sort after uni_rl.algos rewrite

* feat: 降低学术场景跨双仓摩擦——new algorithm recipe + 约定式 CLI 路由(issue #1487) (#1488)

* feat(cli): convention-based routing for custom algorithms (issue #1487)

* docs: document new algorithm recipe and extension tiers (issue #1487)

* docs: update unilab_rl repo URLs after rename (issue #1487)

* style: apply ruff formatting (issue #1487)

* chore: remove in-repo T800 task after externalization to engineai_rl_unilab (#1491)

The T800 walk-flat task now lives in the external training repo
https://github.com/unilabsim/engineai_rl_unilab, which consumes the
published unilab wheel (TestPyPI 0.1.0) via UNILAB_EXTRA_REGISTRY_PACKAGES
and Hydra --config-dir (discussion #1489). Remove the in-repo copy:

- drop unilab.tasks.locomotion.t800, its PPO/SAC owner YAMLs, robot XML
  assets, and tests/envs/locomotion/t800
- sync the registry bootstrap, ROBOT_ASSET_SPECS, migration matrix,
  pyproject source-exclude, and .gitignore
- regenerate the support matrix and re-point asset-hosting doc examples
  at G1/MicroDuck

Fixes #1490

* chore: switch unilab-rl distribution from TestPyPI to PyPI (#1492)

unilab-rl 0.2.0 and unilab 0.1.0 are now formally released on PyPI;
drop the explicit testpypi uv index and source mapping so unilab-rl
resolves from the default index.

* feat(microduck): port velstand + standup tasks from upstream microduck_rl (#1494)

* feat(microduck): add ground-contact robot model for velstand fall recovery

Port the upstream microduck_rl groundcontact collision geometry (walk ->
groundcontact diff): trunk/hip/head-shell collision geoms and leg
self-collision geoms reclassed to the contact-sensor-visible collision class,
plus the BAM actuator variant and flat scene for the velstand task.

* feat(base): add reset-transaction root pose read for layered reset events

ResetStateTransaction.read_root_pose and Entity.read_reset_root_pose expose
the staged (or default) reset root pose inside a reset transaction so
layered reset events can build on earlier writes without marking rows dirty.
Additive cold-path API; existing write semantics are unchanged.

* feat(microduck): port velstand walk-plus-recovery task from upstream microduck_rl

Register MicroduckVelstandFlat (mujoco owner only): the bam_flat velocity
task plus fall-recovery terms ported from upstream mdp.py -- prone/crouch
reset events, fallen-state hysteresis penalty, recovery success bounty,
upright/height progress potentials, upright-gated feet air time, COM upward
velocity, joint torque rate on BamVoltageAction.applied_torque, and the
fallen_too_long termination, with height/tilt gating added to
head_pose_bias (gated off by default, existing tasks unchanged).

* feat(microduck): port standup task from upstream microduck_rl

* chore: remove in-repo microduck tasks after externalization to microduck_rl_unilab (#1495)

The microduck tasks now live in the external training repo
https://github.com/unilabsim/microduck_rl_unilab. Remove the in-repo copy
following the T800 precedent (#1491):

- drop unilab.tasks.locomotion.microduck, its six PPO owner YAML trees and
  one SAC owner tree, robot XML assets, tests/envs/locomotion/microduck,
  and the microduck alignment audit/compare/rollout scripts
- sync the registry bootstrap, ROBOT_ASSET_SPECS, migration matrix,
  pyproject source-exclude, .gitignore, and the closeout/package-boundary
  tests
- regenerate the support matrix and re-point asset-hosting doc examples
  at the X2 task factory

* chore(deps): bump unilab-rl to 1.0.0 (#1496)

First stable uni_rl release; 0.2.0 -> 1.0.0 is docs-only plus the additive,
backwards-compatible env-contract capabilities extension from 0.3.0
(UniLab issue #1487, all fields optional, cold-path only). Unblocks
downstream microduck_rl_unilab's unilab-rl==1.0.0 adoption.

* docs: refresh product README and installation guides

* docs: move asset hosting details from README to installation guide

Keep the README section as Ecosystem only; the Hugging Face dataset list
and HF_ENDPOINT mirror tip now live in the Runtime Assets section of the
en/zh_CN installation pages.

* ci: add tag-based PyPI release workflow

Triggered by…
TATP-233 added a commit that referenced this pull request Sep 4, 2026
* perf(tasks,managers): eliminate NumPy temporaries in motion tracking hot terms (#1296)

- MotionCommand._refresh_robot_state: single-gather np.take/np.ix_ into the
  destination buffers instead of src[sel][:, ids] double copies.
- _BodyTerm error_exp terms: shared per-instance scratch for the
  subtract/square/sum chain; exp(-e/s^2) computed in place. Same op order,
  bit-identical results.
- joint_pos_limits and the global-anchor/joint error_exp function terms:
  in-place chaining, no temporaries beyond the returned buffer.
- RewardManager.compute: weighted term value goes through a shared scratch
  buffer (result-dtype aware) with out= writes into _step_reward.
- ObservationManager.compute_group: skip the defensive obs.copy() when the
  noise stage already returned a fresh array.

Parity: new tests/tasks/test_motion_term_parity.py asserts bit-identical
outputs vs the naive expressions; existing env/manager integration suites
all pass.

* fix: narrow scratch typing for pyright; apply ruff format

* docs: regenerate support matrix for g1_motion_tracking motrix=Configured

Pre-existing staleness from the mjwarp wiring commit (77ca6dd9); caught by
make test-all's check_docs gate.

* perf(reset): row-scope reset-path body getters and complete mjwarp set_state timing (#1295)

- Surface ResetStateTransaction commit timings (outer wall-clock + backend
  set_state sub-keys) into info["timing"] for manager-based envs; previously
  the backend timing dict was discarded and all set_state_* keys sampled as 0.
- mjwarp set_state now reports the shared keyset with granular
  set_state_reset_upload_ms / set_state_reset_forward_ms /
  set_state_host_cache_refresh_ms (mujoco/motrix emit 0.0 placeholders for
  column stability); legacy collapsed keys removed, stale-key leak fixed by
  filtering collected keys against RESET_DONE_DETAIL_TIMING_KEYS.
- Add SimBackend.get_body_lin_vel_w_rows / get_body_ang_vel_w_rows with
  row-gather overrides on mujoco/motrix; MotionCommand._refresh_robot_state
  uses them on the partial-reset path instead of full-batch reads + slice.
- Wrap reset command/obs compute in scene._scoped_state_reads so repeated
  getter calls across terms are deduplicated.
- Benchmark: extend NP_ENV_STEP_TIMING_KEYS / CSV fields and add the mjwarp
  set_state detail table.

Same-host sac/g1_motion_tracking (8192 envs) reset_done: mujoco 9.38 -> 6.92
ms (-26%), motrix 20.63 -> 13.3-14.2 ms (-31~35%), mjwarp ~flat (set_state
device forward 5.2 ms of 14.6 ms dominates; now quantified via new keyset).

* chore: remove temporary per-term profiling instrumentation (#1292 closeout)

The PROFILING_TEMP hooks from #1293 served their purpose (hot-term report
in https://github.com/unilabsim/UniLab/issues/1293#issuecomment-5408865950)
and are removed now that #1294/#1295/#1296 are merged. Removes
src/unilab/utils/term_profiling.py, its tests, the utils whitelist entry,
and all call sites; no functional change to manager logic.

* Revert "Merge PR #1298: perf(managers): per-term isfinite 扫描降级为采样/可配置(#1294)"

This reverts commit c691a61377fb7b2a3eb49c8cc7d8fbb58be47a17, reversing
changes made to 2f7d97f4b22f47c435332f2bf01965394295f342.

* perf(motion): move fixed hot terms to numba kernels

* perf(body-state): fuse selected body cache copies

* refactor(motrix): drop temporary body-state kernel

* perf(motion): fuse command metrics in numba (#1317) (#1320)

* perf(motion): fuse relative transforms in numba (#1318) (#1321)

* perf(observations): reduce batch pipeline copies (#1319) (#1322)

* perf(env): confine DP collector host compute to the per-rank CPU block

Multi-rank off-policy collectors pin their MuJoCo BatchEnvPool workers to
a per-rank CPU block via EnvCfg.cpu_ids, but the collector's host-side
compute did not follow: Numba's parallel kernels sized their pool from the
host CPU count and drifted across rank boundaries, and the OpenBLAS pool
spawned at import kept the host-wide mask.

NpEnv.__init__ now applies apply_env_cpu_runtime(cfg.cpu_ids) on the cold
path: the process is confined to the block (existing threads pinned
individually via /proc/self/task, later threads — including Numba's
lazily-launched pool — inherit the mask) and Numba's pool is sized to
len(cpu_ids) unless NUMBA_NUM_THREADS is set explicitly. cpu_ids=None keeps
the single-rank path bit-identical. Backend-agnostic: any env declaring
cpu_ids (e.g. motrix once it grows affinity support) gets the same
confinement.

* perf(mjwarp): reduce motion tracking reset latency

* benchmark(env): add MuJoCo pool thread-scaling and env-step phase-CPU probes (#1328)

Diagnostic probes for the SAC/MuJoCo single-GPU collector CPU
under-utilization report: pool thread-count scaling on the G1 scene, and
per-phase wall/CPU attribution of a full task env step. New files only;
no behavior change.

* fix(base): type-check and test cpu_runtime on non-Linux hosts

os.sched_setaffinity/sched_getaffinity are Linux-only, so mypy on darwin
rejected the direct attribute access (attr-defined) and the unit tests'
monkeypatch.setattr/delattr failed because the attributes do not exist.

Resolve the affinity symbols via getattr at call time (identical runtime
semantics, still monkeypatchable) and pass raising=False to the test
monkeypatch seams so they work whether or not the host exposes them.

* fix(logging): make collector reward reporting timely

Reward displays (tensorboard reward/mean and the terminal logger) lagged
badly on off-policy and APPO runs:

- collectors sent metrics only every num_envs * 10 env steps, so the
  reported reward changed just once per ~10 learner iterations;
- runners then averaged the last 100 (off-policy) or 50 (APPO) reports,
  each already a rolling 100-episode mean, delaying the visible curve by
  ~1000 iterations.

Report metrics every collector cycle, keep the runner-side window at the
last 10 reports, and bound the per-worker episode reward/length buffers
with deque(maxlen=100) instead of lists that grew for the whole run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(env): keep per-step reward log entries through autoreset

ManagerBasedRlEnv.reset() replaced state.info["log"] with the reset-only
extras (Episode_Reward/*), wiping the fresh per-step reward/* entries that
_update_state_in_read_phase() had just computed for the current transition.
On any step where at least one env resets — with thousands of envs, nearly
every step — collectors therefore saw no reward/* keys at all, so the
per-term reward components in tensorboard and the terminal logger stayed
frozen at one stale value for thousands of iterations (observed as long
flat staircases on reward/motion_* etc.).

Merge instead of replace on the autoreset path: the pre-reset per-step
entries stay, reset extras layer on top. Standalone (non-autoreset) resets
are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(mujoco): size default pool threads to effective CPUs, not 2x oversubscription (#1328)

Single-GPU (cpu_ids=None) BatchEnvPool sizing now uses the CPUs actually
usable by this process (len(os.sched_getaffinity(0)), falling back to
os.cpu_count() where unavailable) instead of 2*cpu_count(). Measured on
this machine (sac + g1_motion_tracking + mujoco, num_envs=4096, 2000 iters,
steady-state second-half median): 86,162 -> 96,262 steps/s (+11.7%),
collector env_step 47.6ms -> 41.9ms. Explicit cpu_ids still fixes
nthread = len(cpu_ids).

* fix(logging): widen terminal reward name column by 10 chars

Reward component names in the Rich live table were truncated with an
ellipsis at 21 chars (e.g. "motion global root…"); widen the fixed
Rewards column to 31 so longer motion-tracking component names render
in full.

* docs(backends): add IsaacGym runtime setup script and guide

IsaacGym (Preview 4, EOL) only supports Python 3.6-3.8 and cannot live in
the main uv environment. Add an idempotent installer that builds a
self-contained external runtime under UNILAB_ISAACGYM_HOME (dedicated
miniconda + py3.8 hsgym env + user-provided Preview 4 tarball), aligned
with the UNILAB_BENCHMARK_HSGYM_* env vars used by the existing physics
benchmark. Add bilingual backend docs covering automated setup, benchmark
verification, manual fallback steps, and troubleshooting.

Refs #1332 #1334

* feat(backend): add IsaacGym subprocess backend

IsaacGym Preview 4 (EOL) only supports Python 3.6-3.8, so the backend runs
physics in a dedicated worker subprocess: the main-process IsaacGymBackend
keeps the numpy SimBackend surface, exchanges ctrl/state through shared
memory slots, and drives the worker over a length-prefixed pipe protocol
(INIT/ATTACH_SLOTS/STEP/SET_STATE/REFRESH/GET_META/SHUTDOWN). The worker is
Python 3.8 compatible, imports no unilab code, and converts IsaacGym xyzw /
world-frame conventions to the repo's wxyz contract at the boundary.

Runtime discovery uses UNILAB_ISAACGYM_PYTHON / UNILAB_ISAACGYM_HOME and
points at scripts/tools/setup_isaacgym_env.sh when missing. Sensor names
are mapped to tensor-API quantities at init (gyro, velocimeter, framequat,
framepos, contact-found); unmappable sensors and optional capabilities
(DR, render, playback) fail closed. Hot-path getters read shared memory
views with zero IPC round trips. Tests run against a mock worker speaking
the same protocol; real-runtime conformance cases skip without the Python
3.8 runtime.

Refs #1332 #1336

* feat(backend): map framezaxis sensors with exact site frames

g1's upvector sensors are framezaxis on sites (imu_in_pelvis,
imu_in_torso, left/right_foot), and g1_walk_flat's policy obs depends on
torso_upvector. Sites are rigidly attached to bodies, so the cold-path
scan now records each site's local pos/quat and the hot path composes it
with the body quaternion from the shared-memory body_state slot - exact,
with no extra IPC. Sites declaring euler/axisangle/xyaxes/zaxis
orientation fail closed, as do non-body/site objtypes.

Refs #1332 #1336

* feat(config): onboard g1_walk_flat to the isaacgym backend

Register G1WalkFlat for sim_backend=isaacgym and add isaacgym owner YAMLs
for the ppo/appo/sac/td3/flashsac trees with DENYLIST parity against the
mujoco owner (verified zero-diff via the generalized audit script, which
now covers the mujoco/isaacgym pair). PD-gain randomization is disabled
(effort-mode dofs) and playback is disabled at the config level until the
backend grows a playback plan. Support matrix gains the IsaacGym column
with Configured-level entries (no real-runtime validation yet).

Refs #1332 #1337

* docs(backends): refresh isaacgym page status after backend landing

Refs #1332 #1337

* cleanup: drop never-runnable G1 deploy scripts, bind docs to task owners

scripts/deploy/ (6 files, 1715 LOC) hard-coded owner-derived values instead
of reading them through Hydra, violating Config first / Fix at owner layer.
All defects below were reproduced on this branch's head:

- sim_prototype.py never ran since 1c0a008f: it looks up MuJoCo sensor
  "gyro", but robots/g1/ only ever exposed pelvis_gyro / torso_gyro (true at
  the introducing commit too, and still true here). The documented
  pre-bringup obs validation therefore never executed once.
- Docs bound the exporter to a task owner whose actor obs width and action
  scale both disagree with the 2.0 / 514 the exporter emits. The ONNX width
  check catches the obs mismatch but the action scale would be silently off.
- --enable-zero-anchor-pos / --enable-zero-linvel used
  action="store_true" with default=True, so neither could ever be False,
  leaving two layout branches and two segment aliases unreachable.
- sim_prototype used the XML timestep (substeps=3) while the owner declares
  sim_dt 0.005 (substeps=4), so the "same config" check stepped at a
  different rate.
- export_deploy_config.py rejected the registered 23-dof owners
  (Expected 29 actuators, got 23).
- warmup/cooldown/motion_primitives (688 LOC) plus 7 emitted config fields
  had zero in-repo consumers.

test_obs_alignment_g1_wbt.py keeps the contract it actually guards: the
training-side per-term history and term-major assembly is now compared
directly against per-term deque semantics, dropping the importlib hop
through sim_prototype.ObsAssembler. The H=1 case proves plain layout-order
concat on its own.

Deployment docs (zh/en symmetric) now derive the hardware contract from the
Manager-Based task owner YAML and its scene XML: actor width is the sum of
dim * history_length over env.observations.actor.terms, and the action scale
comes from env.actions.joint_pos.scale, whose scalar vs regex-map form must
be reproduced exactly - no averaging, single-entry pick, or silent
broadcast. The 514 / 154 widths and the scale 2.0 quoted there were read off
the composed configs on this branch. Existing eval/train command examples
are left as they were; only claims about the removed tooling changed.

Training side untouched: no conf/ or src/ changes.

Validation on this head: ruff format --check (543 files) and ruff check
clean, mypy 248 files clean, pyright 0 errors, benchmark smoke 35/36 +
36/37 (1 platform-optional skip), Sphinx -n build succeeds with zero
warnings, full-repo sweep for the removed paths returns zero hits.
pytest -m "not slow": 2305 passed, 29 skipped, 1 xfailed at 74.89%
coverage, 4 failed - all 4 (go2w manager runtime, t800 registry metadata,
go2 terrain spawn, legacy env package closeout) reproduce identically on the
pristine base and are unrelated to these files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(reset-state): replace np.unique duplicate check with bincount

The reset-state transaction re-validates env_ids on every write (~6 calls
per env step, including one full-batch 4096-id begin per step). np.unique
sorts; bincount on the already range-checked ids is semantically identical
and ~30x faster at 4096 ids (~145us -> ~5us), ~4x at typical 138-row
partial resets.

Issue #1352 (roadmap #1348).

* feat(isaacgym): auto-download the Preview 4 tarball in setup script

The NVIDIA download URL redirects to a signed URL without login, so the
installer fetches the tarball itself (curl -fL with gzip validation);
--tarball remains as the offline escape hatch. Docs updated accordingly.

Refs #1332 #1334

* feat(config): drop appo/td3/flashsac isaacgym owners for g1_walk_flat

Keep the isaacgym onboarding surface at ppo + sac per maintainer decision;
unowned algo trees stay at REGISTERED level in the support matrix.

Refs #1332 #1337

* perf(motion): defer row-wise metrics to reset(), dedup reset motion gather

MotionCommand._update_metrics ran one Numba kernel over all rows every
step, but the 13 row-wise error metrics are consumed only by
CommandTerm.reset (episode log extras; reward/termination terms read
command buffers directly). The per-step path now skips the kernel;
reset() refreshes exactly the rows it reads from the same post-step
buffers, keeping consumed values bit-identical (~0.36ms/step at 4096
envs on Ryzen 9 9950X3D).

The reset path also gathered the resampled motion frames twice
(_resample_command, then _refresh_motion from _update_command; three
times for BoxMotionCommand). _resample_command now ingests the gathered
rows into the reference buffers via _ingest_motion_rows, and the
reset-path _update_command reuses them (~0.14ms/step at 4096 envs).

Issue #1355 (roadmap #1348).

* perf(obs): draw reset-path observation noise for reset rows only

The row-scoped reset path in ObservationManager.compute_group previously
drew noise with full-batch shapes so the shared RNG stream matched the
full-batch implementation exactly. That contract cost a full-batch RNG
draw + transform per noised term per reset (~0.7ms/step at 4096 envs for
g1_motion_tracking, where ~3.4% of rows reset each step).

Issue #1348 removed the RNG-stream parity requirement globally. The reset
path now slices term outputs to the reset rows before noise application,
drawing (n_reset, dim) instead of (num_envs, dim). Term funcs still run
full-batch (their (num_envs, ...) return contract is unchanged); reset
rows' un-noised values stay bit-identical to the full-batch compute, and
the per-step path is untouched.

Issue #1349 (roadmap #1348).

* style: apply ruff format to test_observation_partial_reset.py

Formatting left behind by the #1358 merge (make test-all's ruff format
rewrote the file after the branch head was committed).

* perf(noise): draw float32 noise directly instead of float64+astype

rng.random/standard_normal with dtype=np.float32 skips the float64
generation and the astype pass: ~2x faster draws (643us -> 338us for a
(4096, 64) block on Ryzen 9 9950X3D). The original slice plan of fusing
per-term draws into one block draw was measured and rejected — draw cost
scales with element count, not call count (fused 996us vs per-term 895us
for the g1 actor group), so only the dtype fast path is kept.

Bit-level noise values differ from the float64 path; the RNG-stream
parity requirement was removed globally in #1348. Noise distribution,
range, and in-place op order are unchanged.

Issue #1350 (roadmap #1348).

* perf(obs): share identical term computations across observation groups

Observation groups frequently repeat terms verbatim (e.g. g1_motion_tracking
reuses eight actor terms in the critic group via YAML anchors). Term func
outputs are never mutated in place by the per-term pipeline (noise/clip/scale
operate on fresh arrays; temporal buffers copy), so within one compute() call
the raw outputs of terms with identical func+params are interchangeable.

The manager now builds a per-group share key map on the cold path (plain
function terms with hashable params only; class terms are excluded) and
reuses the first group's raw result for later groups in the same compute()
call, saving one full-batch evaluation per duplicated term per step
(~0.5ms/step at 4096 envs for g1_motion_tracking).

Issue #1351 (roadmap #1348).

* docs(motion): document the _ingest_motion_rows subclass contract

Clarify on _refresh_motion and _resample_command that reset-path row
refreshes may be served by _ingest_motion_rows from _resample_command
(issue #1355), so MotionCommand subclasses overriding _refresh_motion
must mirror additions in _ingest_motion_rows (as BoxMotionCommand does).
Comment-only; no behavior change.

* fix(isaacgym): real-runtime fixes from first end-to-end bring-up

- worker: import isaacgym before torch (enforced by gymapi), and keep a
  private fd for the protocol pipe while rerouting fd 1 to stderr so the
  native banners cannot corrupt framed messages
- worker/backend: apply the scene keyframe as the initial state at INIT
  (name-mapped dof positions, wxyz->xyzw root), so get_default_dof_pos
  matches the post-INIT state as the conformance contract requires
- dependencies: prepend the hsgym env bin/ to PATH so ninja is reachable
  for the one-time gymtorch JIT compile; drop the stale manual-tarball hint
- setup script: self-check imports gymtorch too, pre-warming the JIT
  compile at install time instead of inside the first INIT handshake

Real-runtime conformance now passes: pytest tests/base -m slow -k isaacgym
-> 4 passed.

Refs #1332 #1336

* fix(isaacgym): lazy scene metadata and lazy materialize for env construction

The env constructor reads keyframe/default-qpos metadata before
materialize() (resolve_scene_default_qpos), so XML-scanned metadata is now
loaded lazily without the worker, and the worker handshake itself is
triggered lazily on first state access. Worker body/dof names are validated
against the MJCF document order at INIT (fail-closed on mismatch).

Real-machine: SAC g1_walk_flat/isaacgym runs 3/3 iterations to completion.

Refs #1332 #1336

* feat(cli): expose isaacgym via --sim and document the training guide

- cli: add isaacgym to SUPPORTED_SIMS with a runtime-availability check
  pointing at scripts/tools/setup_isaacgym_env.sh
- docs: bilingual training section with canonical CLI examples (train for
  sac/ppo, common Hydra overrides, GPU selection) and an honest sim2sim
  note: checkpoints transfer across backends, while eval playback is not
  implemented yet (play_render_mode: none skips playback at script level)

Verified on the real runtime: uv run train --algo sac --task g1_walk_flat
--sim isaacgym completes and writes checkpoints.

Refs #1332

* fix(isaacgym): reproduce MJCF position actuators and disable self-collision

Two real-runtime bugs made SAC on g1_walk_flat/isaacgym unable to learn
(reward stuck at -3.5, episode length ~14):

- ctrl semantics: the worker applied ctrl as raw torques via
  DOF_MODE_EFFORT, but SimBackend.step(ctrl) carries backend-native
  actuator input and the G1 MJCF uses <position kp kv forcerange>
  actuators, so position targets were injected as ~1 Nm torques. The
  worker now uses DOF_MODE_POS with set_dof_position_target_tensor, and
  the PD gains/forcerange/armature/frictionloss are parsed from the MJCF
  on the cold path (sensors.py, with MJCF default-class resolution) and
  pushed to the worker by name at INIT, because IsaacGym's importer drops
  kv, frictionloss, and joint ranges. get_actuator_gains()/
  get_actuator_ctrl_range()/get_joint_range() now answer from the XML.
- self-collision: create_actor passed collision filter=0 (on), but the
  G1 collision capsules overlap at the default pose (MuJoCo excludes
  those pairs via <contact><exclude>), generating permanent contact
  forces that railed the wrist/hip joints. Disabled via filter=1, the
  ecosystem-standard superset approximation.

Validated on hardware (RTX 4090): SAC g1_walk_flat isaacgym reaches
final mean reward 248 / episode length 1000 at 10.2M steps, tracking the
mujoco baseline (244-288, 934-1000). Adds a slow hold-pose regression
test and promotes SAC g1_walk_flat isaacgym to Tested in the support
matrix.

* feat(isaacgym): native viewer/camera rendering for eval playback

Implements the SimBackend play contract on the isaacgym subprocess
backend so policy eval no longer skips playback:

- worker: the sim is created with a graphics context whenever it runs on
  a GPU device (no window until create_viewer), enabling both the
  interactive gym viewer and headless camera-sensor capture; new
  INIT_RENDERER/RENDER_FRAME/CAPTURE_FRAME protocol commands, with
  viewer-closed detection surfaced as RenderClosedError on the host and
  a spherical tracking camera bound to env 0's root.
- host: get_play_capabilities (interactive renderer + native video
  capture), resolve_play_render_plan (auto = interactive when a display
  is reachable, record otherwise), init_renderer/render/
  capture_video_frame IPC forwarding, and run_playback via a new
  playback.py mirroring the motrix interactive/record loops.
- configs: drop play_render_mode=none from the g1_walk_flat isaacgym
  owners so train flows straight into eval playback.
- tests: protocol-level coverage through the mock worker (plan
  resolution, viewer lifecycle, capture roundtrip, record video writing,
  graceful window close) plus a real-runtime slow test for camera
  capture; owner contract test updated for auto mode.
- docs: isaacgym guide (en/zh) documents eval/playback usage and the
  GPU-graphics requirement; support matrix note regenerated.

Validated on hardware: uv run eval --render-mode record writes a correct
play_video.mp4 from a trained SAC checkpoint, the interactive viewer
renders live frames, and a short uv run train run chains into record
playback automatically.

Refs #1365

* research(isaacsim): record feasibility contract matrix

* refactor: extract shared locomotion sensor reward terms (#1371)

- common/sensor_reward_terms.py owns the named-sensor reward equations
  (track_lin_vel/track_ang_vel/lin_vel_z/ang_vel_xy/orientation) once, with
  the robot's sensor name bound via cfg params instead of family subclasses.
- common/sensor_terms.py converges to the pure SensorTermBase cold-path
  binding contract; file name and contents now match.
- common/manager_terms.py gains the shared unconditional alive term.
- randomize_encoder_bias moves to unilab.envs.mdp.events; the motion_tracking
  g1 module keeps a compatibility alias.

Refs #1371

* refactor: migrate g1/t800 owners onto shared sensor reward terms (#1371)

The five named-sensor reward equations and the unconditional alive term now
have a single owner in tasks/locomotion/common; g1/manager_terms.py drops its
copies and keeps only G1-specific terms (gait, pose, penalty, command,
curriculum). All g1_walk_flat and t800_walk_flat owners (ppo/appo/sac/td3/
flashsac) bind their robot's sensors via params in their owner YAMLs.
base_height switches to the identical common.manager_terms.base_height_l2
equation. t800 keeps its g1-owned gait/command/curriculum references; that
shared-biped extraction is left to a follow-up.

Refs #1371

* refactor: drop the motion_tracking encoder-bias re-export (#1371)

The two wbt_obs owners reference unilab.envs.mdp.randomize_encoder_bias
directly; the motion_tracking g1 module no longer re-exports the mdp event.

* refactor: fold SensorTermBase into common.manager_terms (#1371)

common/sensor_terms.py held only the binding base class and no actual terms,
so the module name and contents disagreed. SensorTermBase is generic
manager-term infrastructure (its consumers include a termination term), so it
moves to common/manager_terms.py next to the other shared term bases and
plain state/action terms; sensor_reward_terms.py keeps only the sensor reward
equations. g1/t800/sensor_reward_terms imports follow.

* refactor: share the command-resolution helper in locomotion common (#1371)

sensor_reward_terms no longer carries a second _command copy with a divergent
error message; it uses the common manager_terms helper (the codebase's
fail-closed 'capability unavailable' idiom).

* feat: port MicroDuck velocity task to Manager-Based API

Adapt #1368 to the #1042 runtime and keep the 47 STL assets in Hugging Face instead of UniLab Git history.

* refactor: migrate MicroDuck onto shared locomotion terms (#1371)

- Owner YAML now wires velocity tracking, lin_vel_z/ang_vel_xy/orientation
  from common.sensor_reward_terms with explicit sensor_name params
  (local_linvel/gyro/upvector), alive/base_height_l2 from
  common.manager_terms, and randomize_encoder_bias from unilab.envs.mdp.
- microduck/manager_terms.py drops the duplicated generic terms and keeps
  only MicroDuck-specific semantics; SensorTermBase comes from
  common.manager_terms after the common/sensor_terms.py fold.

* research(genesis): SimBackend 契约可行性探针与 go/no-go 报告 (#1372)

- probe_contract.py: 冷路径元数据/状态布局/控制/set_state/keyframe/全局 option/传感器/DR 对表 mujoco ground truth
- probe_runtime.py: init/destroy 生命周期、双 Scene、宿主 torch 副作用、离屏渲染
- probe_perf.py: 256/2048/4096 envs 下 NumPy 边界 D2H/H2D 成本
- REPORT.md: 能力矩阵(实测/源码推断/未验证三档证据)、A/B 对比、Child 2 映射决策;结论 go(方案 A 进程内薄 adapter,附四个前置条件)

探针复现: uv run --with genesis-world==1.3.3 python scripts/tools/genesis_feasibility/<probe>.py(三脚本 exit=0 实测)

* chore(deps): torch 窗口升级至 2.8(#1376)

genesis-world 1.3.3 声明不支持 torch<2.8,且 Child 1(#1372)实测
观测到传感器组合在 torch 2.7 下崩溃一例。maintainer 决策升级窗口。

- pyproject: torch==2.7.0 -> torch==2.8.0(非 aarch64 行;aarch64 已为 2.9.0)
- uv.lock 重生成(torch 2.8.0+cu128,triton 3.3->3.4,nccl/nvjitlink 联动更新)

* test(scripts): 锁文件守卫同步 torch 2.8.0+cu128 期望(#1376)

* feat(backend): GenesisBackend contract adapter 实现(#1378)

- src/unilab/base/backend/genesis/:dependencies(lazy optional import +
  genesis-world==1.3.3 钉扎 + GenesisDependencyError)、materialization
  (mujoco 冷路径扫描 keyframe/sensor/actuator/全局 option、torch 全局
  快照恢复、一进程一 gs.init 会话守卫、fail-closed option 映射)、
  backend(link 寻址 root state、control_dofs_position + nsteps 循环
  实现 set_pre_step_control、step/reset 屏障单次 D2H host cache、
  传感器等价物、DR 只声明实测项、geom 名称等能力 fail-closed)
- factory/registry 接入:create_backend genesis 分支 + GENESIS_AVAILABLE;
  registry._SUPPORTED_SIM_BACKENDS 增加 genesis
- EnvCfg 新增 4 个 Optional genesis 字段(integrator/constraint_solver/
  friction_cone/solver_iterations,补偿 MJCF 全局 option 丢弃)
- pyproject 增加 genesis optional extra(不进基础安装);setuptools
  解除 <70 pin(quadrants==1.3.0 要求 >=77,仓库无运行时引用)
- 测试:fake runtime(镜像 1.3.3 API)17 项 mock 测试 + conformance
  genesis 参数化(无 runtime 清晰 skip)+ 真实 runtime slow lane
  (g1 scene_flat.xml acceptance smoke + re-init 守卫)

设计依据:scripts/tools/genesis_feasibility/REPORT.md §5 映射决策(#1372)

* feat: add SAC off-policy owner for the MicroDuck velocity task

The MicroDuck recipe has a single legacy baseline (PPO), so the SAC owner
keeps env/reward semantics identical to the on-policy owner; the mujoco leaf
carries only backend/algo identity (num_envs 2048, learning_starts 10,
updates_per_step 8, alpha_init 0.001).

Validated: 10-iter smoke (train -> checkpoint -> ONNX export max_diff 7.5e-08
-> play render); 2000-iter trend run shows reward/mean 0.22 -> 0.90 and
episode length 13 -> 48 at 4.1M env steps; test_config_system sweeps the new
owner automatically (163 passed).

* feat(config): g1_walk_flat 接入 genesis 后端与中英文文档(#1380)

- conf/ppo/task/g1_walk_flat/genesis.yaml:继承 base,DENYLIST 字段跨
  后端一致;genesis_integrator=implicitfast 补偿 MJCF 全局 option 丢弃;
  play_render_mode=none
- registry:G1WalkFlat 注册 genesis 变体;cli SUPPORTED_SIMS + runtime
  检查分支
- audit_sim2sim_contracts 增加 (mujoco, genesis) 对(VERDICT: TRANSFERABLE)
- support_matrix 增加 genesis 列(experimental/Configured,诚实标注
  unsupported 边界与 xfailed smoke)
- docs:5-genesis.md 中英文页面(安装/torch>=2.8/一进程一 gs.init/
  unsupported 清单),明确记录 materialize 生命周期缺口
- 测试:owner contract(ppo-genesis 用例 + slow lane 真实 runtime
  smoke,因 #1382 暂 strict xfail)、env config compose、CLI 路由、
  sim2sim resolver、audit/support matrix 断言

真实 runtime smoke 当前阻塞于 #1382(GenesisBackend materialize 生命
周期 + 体帧运动学),修复合入后去 xfail 复跑

* fix(backend): GenesisBackend materialize 幂等/lazy 化与体帧运动学(#1382)

- materialize() 改为幂等 + 首次状态访问 lazy 触发(isaacgym 模式),
  匹配 ManagerBasedRlEnv 先 EntityScene 校验后 materialize 的构造顺序;
  _require_running -> _require_state(13 处);保持一进程一 gs.init 守卫
- 实现 get_body_pos_b/get_body_quat_b,补 get_body_lin_vel_b/ang_vel_b
  的 materialize 守卫(此前 pre-materialize AttributeError);体帧量
  全部从既有 host cache 派生,热路径零新增 D2H;真实 runtime 对照
  mujoco 后端同姿态数值 atol=2e-3 一致(pelvis/hip/knee 三 body 四
  getter)
- mock:lifecycle 用例改幂等语义 + constructor lazy-build 断言
  (build_count 0->1->1)+ 体帧解析断言
- re-init 守卫测试经 _reset_session_state_for_tests 恢复会话标记,
  消除同 pytest 进程内对后续 genesis lane 的干扰

解锁 #1380 真实 runtime smoke(compose->构造->reset->12 步->play
none/record->cleanup 已在本分支等价验证)

* feat(config): 解锁 genesis owner 真实 runtime smoke 并同步文档(#1380)

- #1383(materialize 幂等/lazy + 体帧运动学)合入后去掉
  test_g1_walk_flat_genesis_owner_real_runtime_smoke 的 strict xfail:
  真机 slow lane 通过(18.26s,compose->构造->keyframe reset->12 步->
  play none/record->cleanup)
- 5-genesis.md 中英文:支持证据更新为含真机 smoke;lifecycle 段落改写
  为已修复事实(幂等/lazy materialize);unsupported 清单移除体帧运动学
- support_matrix.py genesis 注记同步(移除 xfail 描述与体帧条目),
  重新生成 support_matrix.md

* feat: add mjwarp backend for the MicroDuck SAC owner

- Register MicroduckVelocityFlat for the mjwarp backend and add the SAC
  mjwarp leaf: capacity knobs (nconmax 128 / njmax 256, render_spacing 2.0)
  and play_render_mode=record, matching the established mjwarp owner
  pattern; algo identity unchanged (num_envs 2048, learning_starts 10,
  updates_per_step 8, alpha_init 0.001, max_iterations 10000).
- Disable base_com (body_ipos reset payload) and push_robot (interval
  velocity push) in the mjwarp leaf: the backend advertises no
  reset-payload or interval DR capabilities yet. encoder_bias stays
  (host-side Entity data surface, backend-agnostic).
- SAC base tuning: alive weight 0.1 -> 10.0.
- Registry contract test now pins available_backends [mujoco, mjwarp];
  support matrix regenerated.

Validated: 10-iter mjwarp smoke (train -> checkpoint -> ONNX max_diff
7.5e-08 -> record playback); make test-all green (2404 passed).

* feat(config): g1_walk_flat SAC owner 接入 genesis 训练支持(#1386)

- conf/sac/task/g1_walk_flat/genesis.yaml:algo 块与 mujoco SAC owner
  逐项一致保 DENYLIST parity;genesis_integrator=implicitfast;
  play_render_mode=none;kp/kd reset 随机化保持启用(genesis 声明实测
  RESET_TERM_KP/KD DR,不同于 isaacgym owner 的 pd_gains: null)
- 测试:owner contract 追加 sac-genesis 用例;真实 runtime smoke 参数化
  ppo/sac 两树(子进程隔离满足一进程一 gs.init);compose/audit/
  support matrix 断言同步
- 支持矩阵 genesis 列追加 SAC cell(Configured);5-genesis.md 中英文
  更新为 PPO+SAC 并加 SAC canonical 命令

真机验证(RTX 4090 / torch 2.8.0+cu128 / genesis-world 1.3.3):
env smoke ppo+sac 2 passed;audit sac mujoco<->genesis TRANSFERABLE;
SAC 短训练回路 smoke(num_envs=64, 3 iterations)端到端通过,loss/熵
正常、checkpoint model_1/2/3.pt 落盘

* feat(backend): genesis eval 原生渲染打通(viewer + 离屏录制)(#1388)

- playback.py(新增):display_available(:0/wayland-0,
  isaacgym 语义)、MuJoCo 球坐标 camera pose 映射、run_genesis_playback
  (live scene 驱动:interactive 60Hz / record 逐帧 capture 写 mp4)
- backend.py:get_play_capabilities/resolve_play_render_plan(none/auto/
  interactive/record 四模式与 isaacgym 对齐,非法值 fail-closed)/
  init_renderer(post-build lazy 挂 viewer/camera,(headless,capture)
  pinning)/render(自初始化 + RenderClosedError 翻译 genesis 私有异常
  + is_alive 双保险)/capture_video_frame/run_playback;close() 停
  viewer;render 全在冷/playback 路径,step 热路径零依赖
- owner YAML(ppo+sac):play_render_mode none->auto
- 测试:fake runtime 增 camera/viewer/visualizer 面;8 个新 mock 测试;
  conformance 相机捕获用例;真机 slow lane record E2E + interactive
  三帧实测;owner 断言 auto
- 文档:5-genesis.md 中英文 playback/render 段落重写;支持矩阵同步

真机实测(RTX 4090 / DISPLAY=:0 / torch 2.8.0+cu128 / genesis-world
1.3.3):record eval 端到端产出 1280x720@50fps 视频;interactive
post-build viewer 开窗成功、3 帧渲染正常、关窗抛 RenderClosedError

* fix(runner): collector inference tick 超时配置化(#1391)

Genesis 在 materialize 冷路径编译内核(2048 envs 实测 33.8-36.3s,
无跨进程缓存),确定性超出 runner 硬编码的 30s tick-0 预算,完整
训练无法启动。

- DoubleBufferOffPolicyRunner: inference_request_timeout_sec 可选构造
  参数(默认仍 30.0,校验正数),tick 等待与 TimeoutError 消息用
  实例值;不改 lifecycle/同步协议
- fast_sac builder 从 cfg.training.inference_request_timeout_sec 透传
- conf/sac/config.yaml 记录默认 30.0;sac genesis owner 提至 180.0
- 测试:dispatch kwargs 断言 + sac genesis owner compose 断言(180.0)

* fix(backend): viewer 关闭异常在 step 边界翻译为 RenderClosedError(#1393)

Genesis 挂有 viewer 时 scene.step() 内部也会更新 viewer;viewer 关闭
时私有 GenesisException 从 backend.step 逃逸(#1388 只翻译了 render()
路径),真机完整训练的 play 阶段因此 exit 1。

- _raise_if_viewer_closed 统一翻译(render 与 step 共用):viewer 死亡
  时抛契约 RenderClosedError 并从 visualizer 分离死 viewer(保留
  viewer_lock,rasterizer destroy 需要;分离后物理可继续)
- _physics_substep 包装 scene.step 的翻译边界,step 双分支共用
- fake runtime 复刻真实行为:scene.step 更新挂载 viewer、viewer 死亡
  时 update 抛错;playback mock 断言同步为忠实计数
- 测试:mock test_step_translates_viewer_closed;真机 interactive 测试
  增补 step 关闭断言与分离后物理续跑(双 backend 共享会话,不 close)

真机验证:短训练 + auto play 端到端 exit 0(viewer 中途关闭时 INFO
'Render window closed.' 干净退出,原先 exit 1);slow 电池同进程
16 passed;mock 26 passed

* chore(docs): SAC g1_walk_flat genesis 支持等级 Configured → Tested

真机完整训练验证(2026-08-31,RTX 4090 / torch 2.8.0+cu128 /
genesis-world 1.3.3):uv run train --algo sac --task g1_walk_flat
--sim genesis 5000/5000 iterations 完成(reward/mean 6.5 → 244.8,
best 274.5;episode length → 987/1000,timeout_rate → 1.0;10.26M
env steps / 224s wall time;run 2026-08-31_23-04-01_genesis),加
model_5000.pt 的 record playback 步态视频验证——与 isaacgym SAC
Tested 的证据标准一致。

- support_matrix.py: _MAINTAINER_VALIDATED_GENESIS_ENTRYPOINT_TASKS
  加入 (sac_torch, g1_walk_flat) 并记录验证事实;genesis 注记更新
- 5-genesis.md 中英文:SAC Tested(含训练指标)/ PPO 保持 Configured
- 测试断言同步;support_matrix.md 重新生成

* fix(backend): 交互 viewer 相机姿态改用 Z-up 完整矩阵(#1396)

genesis 1.3.3 Viewer.set_camera_pose(pos, lookat) 复用 self._camera_up,
而该值在 Viewer 初始化时被改写为 ViewerOptions 默认相机位姿的相机系
y 轴(≈(-0.49,-0.07,0.87),非世界 Z-up),post-build 挂载后设置相机
被滚转约 30°(真机实测 trackball 矩阵与期望 max diff 0.50),地面
斜向上。离屏 camera 显式传 up=(0,0,1) 故正常。

- playback.camera_pose_matrix_z_up:adapter 自行用 Z-up 构建 4x4
  相机位姿;init_renderer 交互分支改走 pose= 分支(不读 _camera_up)
- mock:断言 viewer 收到 pose 矩阵且 x 轴水平(pose[2,0]==0)
- 真机 slow lane:读回 trackball pose 与期望矩阵 assert_allclose
  (atol=1e-5),防回归;fake 记录 set_camera_pose 全参数

* feat(isaacsim): add headless subprocess backend

Extract shared subprocess IPC primitives, wire IsaacSim 5.1/IsaacLab 2.3 runtime discovery, and register the G1 PPO/SAC owners with fail-closed rendering and randomization boundaries.

* feat: add generic stage-based curriculum engine (#1397)

Port mjlab's envs/mdp/curriculums.py to the NumPy manager runtime:
reward_curriculum ramps a reward term's weight/params and
termination_curriculum ramps a termination term's params/time_out from a
declarative stage table keyed on env.common_step_counter. The shared
_validate_stages/_apply_stages engine checks stage ordering, field
existence, and param-key spelling fail-closed at manager construction.

The manager protocol seam gains reward_manager/get_term_cfg access so the
generic terms can reach the live term cfgs without concrete-env imports.

A demo owner YAML (tests/fixtures/mjlab_cartpole/conf/stage_curriculum.yaml)
declares one ladder per public term, covered by materialization, fail-closed,
and real-runtime ramp tests; a 64-env/2-iteration PPO smoke through
train_rsl_rl.py verified the full training config chain and the
Curriculum/<term>/<field> log extras.

* feat: add generic IMU misalignment observation terms (#1400)

* feat: add shared locomotion reward/termination terms (#1398)

Port mjlab's generic locomotion terms into the shared layers:

- envs/mdp/rewards.py: joint_pos_limits, posture, variable_posture
  (per-joint std Gaussian kernels resolved from {regex: std} mappings).
- envs/mdp/terminations.py: nan_detection over the base-owned entity
  physics state instead of relying on the optional global nan guard.
- tasks/locomotion/common/gait_terms.py: window-form feet_air_time,
  feet_clearance, feet_swing_height, feet_slip and
  angular_momentum_penalty on the SensorTermBase cold-path binding;
  per-foot contact sensor groups mirror mjlab's ContactSensor
  primary/slot any-reduce, foot heights/velocities come from
  body_link_pos_w/body_link_lin_vel_w (exact on flat terrain).

conf/ppo/task/g1_walk_flat/base.yaml now references the shared
feet_air_time (weight 0.25, mjlab window 0.05-0.5 s, command-gated);
the 23-DoF rough owner disables it explicitly because its scene XML
declares no named foot contact sensors.

* feat: extend MBA event/DR terms (#1399)

- add apply_body_impulse step-mode class term with the pinned mjlab
  cooldown->trigger->sustain->expire lifecycle, eccentric force
  application via cross(offset_w, force), and per-backend fail-closed
  capability binding
- extend IntervalRandomizationPlan with body_angular_velocity_delta and
  body_torque; SimBackend.apply_body_force gains an optional torque
  channel (mujoco implements it via xfrc_applied, drake/motrix fail
  closed, mjwarp/isaacgym stay unsupported)
- push_by_setting_velocity now accepts world-frame angular velocity
  ranges; mujoco converts them into the free-root body-frame qvel
  columns using the in-state root orientation
- release min_step_count_between_reset>0 for reset-mode payload terms:
  the reset transaction re-supplies gated rows from the last committed
  per-env field values, preserving keep-current semantics densely
- document the extended interval plan fields in the DR user guide

* feat: add mjwarp per-world DR consumption (#1401)

Expand the declared DR model fields to (nworld, ...) on the cold path
before CUDA graph capture, consume ResetRandomizationPayload rows in
set_state with graded set_const/set_const_0 recomputation, and stage
interval pushes/body forces through data.xfrc_applied plus root velocity
kicks through the existing upload+forward barrier. Re-enable base_com and
push_robot on the microduck SAC mjwarp owner. Per-world gravity DR stays
fail-closed.

* fix: align mjwarp apply_body_force signature with SimBackend torque param (#1401)

* style: ruff format canonical form for #1401

* feat: add step-staged command/event curricula and privileged foot obs terms (#1402)

- envs/mdp/curriculums.py: generic command_curriculum / event_curriculum on
  the S1 stage engine (live term-cfg mutation keyed by common_step_counter);
  the shared stage engine now tolerates param-less command term cfgs.
- events.randomize_rigid_body_com and microduck UniformVectorCommand consume
  their live cfg ranges on every apply/resample so staged CoM-range and
  head-pose-range curricula take effect without rebuilding terms.
- tasks/locomotion/common/gait_terms.py: mjlab velocity critic terms
  foot_height / foot_air_time / foot_contact / foot_contact_forces
  (log1p-compressed forces, per-foot air-time tracking with reset).
- managers/_types.py: expose event_manager + command get_term_cfg on the
  manager-term protocol surface.

* feat: align MicroDuck velocity recipe with legacy via MBA capabilities (#1402)

Owner yaml (PPO + SAC bases kept in sync, alive weight excepted):
- five legacy step-staged curricula (stage step = legacy iter x 24, matching
  num_steps_per_env=24): action_rate -0.1->-1.0, head_pose_bias 0->+3.0,
  standing envs 0.02->0.25, head_pose ranges ->±1.1/±1.4 rad, trunk/head CoM
  ±3->±15/±10mm.
- rewards: window-form air_time (0.125, 0.300)s w=3.0, foot_clearance -2.0,
  foot_swing_height -0.25, foot_slip -0.1, dof_pos_limits -1.0,
  angular_momentum -0.02; angular tracking switches to the merged-omega form
  exp(-((cmd-wz)^2+wx^2+wy^2)/0.5) at w=2.0.
- commands: twist x ±0.4 / y ±0.3 / wz ±1.0, resample (3,8)s, turn-in-place
  15%, rel_forward_envs 0.2; terminations: tilt 70deg, explicit nan_state.
- observations: IMU 0-1 step random delay (period 64), joint_vel fixed 1-step
  delay; critic gains privileged foot terms to 76D (actor 61D untouched).
- DR: foot friction (0.7,1.3), armature x(0.9,1.1), head CoM, push interval
  (3,6)s; mjwarp owner consumes all of them through the S5 payload path.
- deploy_contract: MICRODUCK_CRITIC_OBS_DIM 64 -> 76.

* feat(isaacsim): add eval rendering modes

* fix:Overexposed scene

* fix:fixed camera of interactive model

* fix:floor

* feat(assets): host six robots' meshes/textures on HF and slim the wheel (#1422)

Move g1/go2/a2/allegro_hand/sharpa_wave/go2_arm binary assets (~130 MiB)
out of git onto the existing unilabsim/unilab-robots HF dataset repo:

- hub.py: ROBOT_ASSET_SPECS registry for all HF-hosted robots plus
  ensure_robot_assets_for_paths(); go2_arm/go2w also resolve the shared
  ../go2/assets mesh dir their XMLs reference.
- create_backend() resolves registered robot dirs on the cold path before
  any backend parses XML; config_adapter does the same for play_profile
  scene materialization.
- unilab-pull-assets covers every registered robot and gains '--robot all'.
- pyproject.toml source-exclude keeps the HF-hosted directories out of the
  wheel/sdist (wheel: 55.8 MB -> 12.1 MB); .gitignore ignores the download
  locations; files land back at their original paths so XML meshdir
  references keep working.
- Docs (en/zh) and AGENTS.md updated for the new convention.

* feat: move conf/ and training entrypoints into the unilab package (#1421)

Relocate conf/ to src/unilab/conf/ and the five training entrypoints
(plus train_offpolicy.py and play_interactive.py) to src/unilab/scripts/
so a built wheel is self-contained: train/eval/demo work from any
directory after pip install.

- cli.py / demo.py / cli_completion.py / ipc/dp_launcher.py /
  algos/hora/distill_config.py: replace repo-root assumptions with
  package-resource location; run artifacts (logs/, checkpoints) now
  anchor at the caller's CWD
- error hints switch to pip context (pip install unilab[<extra>])
- pyproject.toml (+rocm): uv_build>=0.12.1,<0.13, authors/keywords/
  classifiers; __version__ reads importlib.metadata (single source)
- top-level dev scripts, tests, docs, CI metadata updated to the new
  paths

* docs: record unisim extraction boundary ADR (#1430)

* feat: connect UniLab consumers to unisim-core (#1433)

* docs: document unisim-core migration (#1434)

* feat: connect UniLab consumers to unisim-core

* docs: document unisim-core migration

* fix: support macOS affinity and cache CI assets

* chore: consume unisim-core 0.1.5

* feat(drake): document and package Drake backend

* feat(drake): add standalone setup path

* feat: remove in-tree physics backend implementations

* docs: finalize unisim-core migration guidance

* style: normalize Drake test imports

* chore: consume unisim-core 0.1.11

* docs: clarify Drake installation and training

* chore: consume unisim-core 0.1.12

* fix(cli): expose Drake simulation route

* test: remove local unisim checkout dependency

* docs: reorganize backend documentation

* fix: preserve interactive viewer camera controls

* feat: route MuJoCo interactive eval to viewer script

* feat(drake): render Drake physics with native MuJoCo

* fix: enable policy actions in interactive eval

* feat(drake): support macOS batch training

* build: use released unisim-core from PyPI

* fix(drake): detect system Eigen headers

* fix(test): make MuJoCo affinity test hermetic

* fix(assets): compact asset download output

* fix(assets): print per-robot pull progress

* test: remove obsolete audit scaffolding

* style: normalize cleaned test modules

* ci: lint test modules for dead names

* test: name registry policy by current contract

* feat: complete minimal manager-based microduck slice

* fix(test): assert removed backend module path

* refactor(mba): keep task semantics out of shared terms

* refactor(mba): keep scalar metric in shared catalog

* feat(microduck): baseline alignment contract and controlled comparison facilities

Issue #1453 (child 1/5 of #1452): declarative owner-layer contract table
against pollen-robotics/microduck_rl @ 29e887ec, read-only audit script,
drift-guard test, and zero-action statistical rollout script for the three
MicroDuck tasks (ppo tree, mjwarp owner).

* fix(assets): aggregate pull summary and silence HF download logs

* fix(assets): drop Namespace deletion; apply ruff format

* feat(microduck): align physics layer with upstream recipe (issue #1454)

- scene_flat.xml <option>: implicitfast/Newton/pyramidal, iterations 10,
  ls_iterations 20, tolerance 1e-8, ls_tolerance 0.01 (upstream mjlab SimCfg
  injection values; timestep stays backend-owned via sim_dt)
- ppo microduck base: sim_dt 0.005 (substeps 4), push_robot per-env interval,
  terminations drop base_height (time_out + tilt 70 deg + nan_detection)
- reset_base event: reset_root_state_uniform xy +-0.5 m, yaw +-pi,
  z +[0, 0.01] on keyframe z=0.12, zero velocities, joints exactly HOME
- randomize_body_mass_inertia event: alpha-only pseudo_inertia slice scaling
  trunk_base mass+inertia by one shared log-uniform factor in [0.95, 1.05],
  sampled once per env and replayed every reset (upstream startup semantics);
  ResetStateTransaction/Entity gain body_inertia bind/write with a
  caller-compiled, mass-cross-validated default table
- alignment_contract: flip physics/solver/events/terminations entries to
  match; tests updated and extended

* feat(microduck): align velocity reward stack with upstream HEAD (issue #1455)

* feat(microduck): align training infra with upstream recipe (issue #1456)

- thread env-level RNG seed through the three microduck mjwarp owners
  (env.seed=42): the Hydra -> BackendAdapter -> registry chain already
  reaches ManagerBasedRlEnvCfg.seed, so command/noise/delay/DR sampling is
  now reproducible across runs; unset owners keep the None default
- fix init_at_random_ep_len: RslRlVecEnvWrapper.episode_length_buf becomes
  a property whose setter propagates into the env's real episode counters
  via the new ManagerBasedRlEnv.set_episode_length_buf cold-path entry
  (keeps episode_length_buf and state.info["steps"] in sync); upstream
  mjlab's wrapper setter writes the env buffer directly, so staggering is
  effective upstream and now matches here
- scale alignment: algo.num_envs 2048 -> 4096 and algo.seed 1 -> 42 in all
  three mjwarp owners; max_iterations 500 -> 2000 (2000 x 24 = 48000 env
  steps, exactly covering every curriculum terminal stage; final budget
  remains a child 5 decision)
- flip infra.num_envs / infra.seed contract entries to match and add an
  infra.env_seed match entry guarding the new YAML field

* feat(scripts): add microduck UniLab vs upstream PPO metrics comparison

Parse rsl_rl tfevents from multiple run dirs per side, aggregate
final-window stats and convergence speed across seeds, and emit a
markdown comparison report plus JSON curve dump. Handles aliased
reward/termination term names between the two codebases, skips
aborted runs with a warning, and degrades gracefully to single-seed
statistics.

Issue #1457 (roadmap #1452 child 5/5).

* docs(microduck): comparison benchmark report against upstream microduck_rl (issue #1457)

* fix: bump tensorboard to >=2.21.0 to drop pkg_resources dependency

setuptools>=82 removed pkg_resources; tensorboard 2.20 imports it at
startup, breaking 'uv run tensorboard'. tensorboard 2.21 switched to
importlib.metadata. The rocm variant pins setuptools<70 and is
unaffected.

* docs(microduck): move comparison report out of repo to PR comment (issue #1457)

The alignment report is experiment evidence, not a maintained artifact;
publish it as a comment on the integration PR instead of tracking it in git.

* feat(cli): route mjwarp interactive eval through the MuJoCo viewer

eval --sim mjwarp --render-mode interactive now launches play_interactive.py
with mjwarp owning the physics rollout and MuJoCo rendering env[0] (forced
single env, same contract Drake already uses). play_interactive binds the
CUDA Warp process device before env construction, matching the offpolicy
train entrypoint. Record playback routing and owner defaults are unchanged;
support matrix prose and mjwarp owner YAML comments updated to match.

* feat(cli): fall back to sibling owner config for eval without backend owner YAML

Eval replays a trained checkpoint, so a missing owner YAML for the
requested backend no longer aborts the route: the CLI reuses a sibling
backend owner of the same task (same profile shape) and re-applies the
requested backend via the sim2sim-allowlisted training.sim_backend
override. The runtime sim2sim preflight still validates the composed
config against the source run contract. Train keeps requiring the owner
YAML to exist.

* fix(locomotion): gate foot contact on the contact-frame normal column (issue #1468)

MuJoCo <contact data="force"> sensors report the force in the contact
frame whose first axis is the contact normal, but the shared locomotion
contact terms gated on column 2 (a tangential component). Every width-3
contact sensor in the repo (microduck, go1, go2, a2) is a contact-frame
force sensor, so the convention is wrong for all current users: standing
microduck reads 96.5% false 'air' time, corrupting feet_air_time /
feet_swing_height / feet_slip rewards and the foot_contact /
foot_air_time observation channels.

Gate on column 0 for both 1-D found and 3-D force sensors in
gait_terms._FootContactTerm, manager_terms.feet_phase_contact and
manager_terms.feet_air_while_standing; pin the convention with
tangential-distractor fixtures and a regression test.

Empirical check (64 envs, mjwarp, seed 42): term gating vs normal-force
ground truth agreement 1.000 standing / 0.998 walking (was 0.052/0.741).

* fix(go2): disable drake contact reward until drake reports contact-frame force (issue #1471)

Cross-backend audit of the #1468 column convention: mujoco and mjwarp pass
raw sensordata through (contact frame, column 0 = normal), motrix forwards
the native contact sensor with the same layout (verified on go1 standing:
~31 N normal in column 0 per foot), genesis/isaacgym/isaacsim fail closed
for 3-D force sensors. drake_uni instead synthesizes per-body net contact
force in the WORLD frame, so the normal component lands in column 2 there;
the go1 drake owner already disabled the contact reward, and this does the
same for go2 ppo/sac drake owners until the drake adapter reports
contact-frame force.

Also document that reduce="netforce" contact sensors report world-frame
force and sit outside the gating contract.

* test(go2): pin drake contact reward as disabled in train-script config test (#1471)

* fix(tools): repair stale install prefix when ISAACGYM/ISAACSIM_HOME was relocated

Conda envs and venvs are not relocatable: entry-point shebangs, activate
scripts, .pth/egg-link files, PEP 660 editable finders and conda-meta all
hard-code the install prefix. After the local cache was moved from the legacy
$HOME/.unilab/{isaacgym,isaacsim} to the current ~/.cache/unisim default,
every skip-check/marker still passed but pip could not execute and the
editable installs vanished from sys.path, breaking resumable installs.

Both setup scripts now detect the stale prefix from the pip shebang and
rewrite it in place (shebang lines, activate scripts, editable finders,
.pth/egg-link, conda-meta), use python -m pip instead of the bin/pip entry
point, and verify isaacgym/isaaclab by import rather than trusting pip show
or a stale marker (isaacsim 06_verify is forced to re-run after a repair).

* feat(microduck): port BAM xl330-m6 voltage actuator model (issue #1474)

NumPy port of the upstream bam.mjlab.BamActuator (xl330-m6 voltage servo)
as a task-owned action term on the Manager-Based runtime, recomputing
motor torque every physics substep through the SimBackend
set_pre_step_control contract (go2w precedent): LIFO command-delay ring
buffer (lag 3-6 substeps), per-env battery model (vin 6.5-8.2 V startup
DR with load-dependent drop), firmware P law with current-limit duty
window and PWM clip, back-EMF torque equation, and the m6 friction
budget (Coulomb + Stribeck + directional load + quadratic) under per-env
friction_scale reset DR.

Approximation boundaries vs upstream (documented in the module
docstring): the friction budget is folded into the output torque with a
torque-domain stiction clip because the SimBackend contract has no
per-substep dof_frictionloss/dof_damping write channel, and the external
torque is a finite-difference estimate since qfrc_* is not exposed.

New task MicroduckVelocityBamFlat (mujoco owner only; the mjwarp
host_numpy profile rejects pre-step control) reuses the PD recipe's
reward/obs/curriculum verbatim so the actuator model is the only delta.

Probe cross-check vs upstream (64 envs, seed 42): zero-action falls
356/64 envs with mean first fall at 64.1 steps (upstream 383, 64),
knee-step t90 403 ms (upstream ~370 ms), steady-state error overall
0.036 rad / knee 0.118 rad (upstream 0.049 / 0.148; stiffer by ~25% from
the torque-domain friction approximation).

* docs: regenerate support matrix for MicroduckVelocityBamFlat (#1474)

* feat: split RL algorithm layer into standalone uni_rl package (roadmap #1476) (#1484)

* feat: consume uni-rl package and remove migrated algorithm layer (issue #1480)

- Depend on published unilab-rl==0.1.0a2 (TestPyPI index) in both
  pyproject.toml and pyproject.rocm.toml; regenerate uv.lock and
  uv.rocm.lock.
- Delete the migrated algorithm layer: src/unilab/algos/, src/unilab/ipc/,
  src/unilab/logging/, and migrated utils/observation helpers, plus their
  tests (119 files).
- Inject env construction into uni_rl runners via the new picklable
  unilab.base.env_factory (registry_env_factory / make_registry_env) and
  bind_backend_process_device in unilab.base.process_device; train_appo and
  train_offpolicy pass env_factory to the uni_rl runner builders.
- Re-home UniLab-owned HORA pieces: play_hora_appo (scripts/) keeps the
  play_fn resolver and sim2sim validation; hora_distill_config (training/)
  keeps teacher-default composition. Callers of uni_rl
  cfg_with_checkpoint_runtime now apply teacher defaults first, matching the
  new uni_rl caller contract.
- Point Hydra class_name/resolver strings and structured config defaults at
  uni_rl.* classes; the HORA APPO resolver stays UniLab-side.
- Workaround: uni_rl 0.1.0a2's double-buffer builder helpers do not forward
  backend_device_binder to DoubleBufferOffPolicyRunner, so train_offpolicy
  sets runner.backend_device_binder post-construction (mjwarp collector path
  only) until uni_rl grows the builder kwarg.
- Update AGENTS.md and sphinx docs to reference the uni_rl package; kept
  api_reference stub pages pointing at the new home.
- Rewrite the architecture guard tests to assert the migrated layers are
  gone from UniLab and not re-defined.

Validation: make test-all green at this head (ruff format/check, mypy 156
files, pyright 0 errors, pytest 2234 passed / 20 skipped / 319 deselected /
1 xfailed, benchmark smoke 36/37 with 1 platform-optional mlx skip).

* build: pin uni-rl 0.1.0 from TestPyPI (issue #1481) (#1483)

- pyproject.toml + pyproject.rocm.toml: unilab-rl==0.1.0a2 -> 0.1.0;
  uv.lock and uv.rocm.lock regenerated (rocm via Makefile sync-rocm swap).
- train_offpolicy.py: drop the post-construction backend_device_binder
  attribute workaround; the kwarg is now forwarded by uni_rl 0.1.0's
  build_*_double_buffer_runner helpers (unilabsim/uni_rl#3).

Validation: uv run pytest -m "not slow" 2234 passed / 20 skipped /
319 deselected / 1 xfailed.

* feat: adopt unilab-rl 0.2.0 algos layout (issue #1485) (#1486)

* feat: adopt unilab-rl 0.2.0 algos layout (issue #1485)

- pyproject.toml + pyproject.rocm.toml: unilab-rl==0.1.0 -> 0.2.0;
  uv.lock / uv.rocm.lock regenerated (rocm via Makefile sync-rocm swap).
- Rewrite uni_rl.{appo,common,fast_sac,fast_td3,flash_sac,him_ppo,hora,
  rsl_rl*} -> uni_rl.algos.<same> across src imports, repo-root scripts,
  tests, conf/**/*.yaml class_name/runtime_resolver strings, and
  structured_configs.py defaults. uni_rl.{ipc,logging,offpolicy,utils,
  env_contract} references unchanged (runtime infra stays top-level in
  unilab-rl 0.2.0).
- AGENTS.md + docs/sphinx: repo references renamed unilabsim/uni_rl ->
  unilabsim/unilab-rl; algo module paths updated to uni_rl.algos.*.

Validation: uv run pytest -m "not slow" 2234 passed / 20 skipped /
319 deselected / 1 xfailed.

* style: ruff format/import-sort after uni_rl.algos rewrite

* feat: 降低学术场景跨双仓摩擦——new algorithm recipe + 约定式 CLI 路由(issue #1487) (#1488)

* feat(cli): convention-based routing for custom algorithms (issue #1487)

* docs: document new algorithm recipe and extension tiers (issue #1487)

* docs: update unilab_rl repo URLs after rename (issue #1487)

* style: apply ruff formatting (issue #1487)

* chore: remove in-repo T800 task after externalization to engineai_rl_unilab (#1491)

The T800 walk-flat task now lives in the external training repo
https://github.com/unilabsim/engineai_rl_unilab, which consumes the
published unilab wheel (TestPyPI 0.1.0) via UNILAB_EXTRA_REGISTRY_PACKAGES
and Hydra --config-dir (discussion #1489). Remove the in-repo copy:

- drop unilab.tasks.locomotion.t800, its PPO/SAC owner YAMLs, robot XML
  assets, and tests/envs/locomotion/t800
- sync the registry bootstrap, ROBOT_ASSET_SPECS, migration matrix,
  pyproject source-exclude, and .gitignore
- regenerate the support matrix and re-point asset-hosting doc examples
  at G1/MicroDuck

Fixes #1490

* chore: switch unilab-rl distribution from TestPyPI to PyPI (#1492)

unilab-rl 0.2.0 and unilab 0.1.0 are now formally released on PyPI;
drop the explicit testpypi uv index and source mapping so unilab-rl
resolves from the default index.

* feat(microduck): port velstand + standup tasks from upstream microduck_rl (#1494)

* feat(microduck): add ground-contact robot model for velstand fall recovery

Port the upstream microduck_rl groundcontact collision geometry (walk ->
groundcontact diff): trunk/hip/head-shell collision geoms and leg
self-collision geoms reclassed to the contact-sensor-visible collision class,
plus the BAM actuator variant and flat scene for the velstand task.

* feat(base): add reset-transaction root pose read for layered reset events

ResetStateTransaction.read_root_pose and Entity.read_reset_root_pose expose
the staged (or default) reset root pose inside a reset transaction so
layered reset events can build on earlier writes without marking rows dirty.
Additive cold-path API; existing write semantics are unchanged.

* feat(microduck): port velstand walk-plus-recovery task from upstream microduck_rl

Register MicroduckVelstandFlat (mujoco owner only): the bam_flat velocity
task plus fall-recovery terms ported from upstream mdp.py -- prone/crouch
reset events, fallen-state hysteresis penalty, recovery success bounty,
upright/height progress potentials, upright-gated feet air time, COM upward
velocity, joint torque rate on BamVoltageAction.applied_torque, and the
fallen_too_long termination, with height/tilt gating added to
head_pose_bias (gated off by default, existing tasks unchanged).

* feat(microduck): port standup task from upstream microduck_rl

* chore: remove in-repo microduck tasks after externalization to microduck_rl_unilab (#1495)

The microduck tasks now live in the external training repo
https://github.com/unilabsim/microduck_rl_unilab. Remove the in-repo copy
following the T800 precedent (#1491):

- drop unilab.tasks.locomotion.microduck, its six PPO owner YAML trees and
  one SAC owner tree, robot XML assets, tests/envs/locomotion/microduck,
  and the microduck alignment audit/compare/rollout scripts
- sync the registry bootstrap, ROBOT_ASSET_SPECS, migration matrix,
  pyproject source-exclude, .gitignore, and the closeout/package-boundary
  tests
- regenerate the support matrix and re-point asset-hosting doc examples
  at the X2 task factory

* chore(deps): bump unilab-rl to 1.0.0 (#1496)

First stable uni_rl release; 0.2.0 -> 1.0.0 is docs-only plus the additive,
backwards-compatible env-contract capabilities extension from 0.3.0
(UniLab issue #1487, all fields optional, cold-path only). Unblocks
downstream microduck_rl_unilab's unilab-rl==1.0.0 adoption.

* docs: refresh product README and installation guides

* docs: move asset hosting details from README to installation guide

Keep the README section as Ecosystem only; the Hugging Face dataset list
and HF_ENDPOINT mirror tip now live in the Runtime Assets section of the
en/zh_CN installation pages.

* ci: add tag-based PyPI release workflow

Triggered by…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant