Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions tests/base/simbackend_contract_baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"abstract": [
"apply_interval_randomization",
"get_actuator_ctrl_range",
"get_base_ang_vel",
"get_base_lin_vel",
"get_base_pos",
"get_base_quat",
"get_body_ang_vel_b",
"get_body_ang_vel_w",
"get_body_ids",
"get_body_lin_vel_b",
"get_body_lin_vel_w",
"get_body_pos_b",
"get_body_pos_w",
"get_body_quat_b",
"get_body_quat_w",
"get_dof_pos",
"get_dof_vel",
"get_dr_capabilities",
"get_init_qvel",
"get_joint_range",
"get_keyframe_qpos",
"get_sensor_data",
"model",
"num_actuators",
"num_dof_vel",
"num_envs",
"set_state",
"step"
],
"optional": [
"apply_body_force",
"apply_body_linear_velocity_delta",
"apply_init_randomization",
"capture_video_frame",
"create_hfield_scanner",
"get_actuator_gains",
"get_body_ipos",
"get_body_mass",
"get_body_subtree_ids",
"get_default_qpos",
"get_dof_armature",
"get_geom_body_ids",
"get_geom_contact_masks",
"get_geom_friction",
"get_geom_id",
"get_geom_names",
"get_geom_size",
"get_gravity",
"get_joint_dof_indices",
"get_joint_dof_pos_indices",
"get_joint_dof_vel_indices",
"get_motion_body_ids",
"get_physics_state",
"get_site_ids",
"get_site_jacobian_w",
"init_renderer",
"render",
"resolve_play_render_plan",
"run_playback"
],
"concrete": [
"cleanup_scene_assets",
"copy_body_state_w",
"get_body_id",
"get_body_pose_w",
"get_body_pose_w_rows",
"get_body_state_w",
"get_body_vel_w",
"get_play_capabilities",
"get_playback_model",
"get_sensor_data_batch",
"get_sensor_data_rows",
"materialize",
"set_pre_step_control"
]
}
80 changes: 80 additions & 0 deletions tests/base/test_simbackend_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""SimBackend public-method contract inventory (Phase 6 safety net; locks current contract).

Pins the *name sets* of the three method categories of ``SimBackend``:
- ``abstract`` : ``@abc.abstractmethod`` (required — subclasses must implement)
- ``optional`` : concrete body that ``raise NotImplementedError`` (subclass may override)
- ``concrete`` : real default implementation

b.md b4: lock the NAME SETS, not just counts — a "delete one required abstract + mis-promote
one optional" swap leaves the count unchanged but breaks the contract. When Phase 6 splits
SimBackend into 6 Mixin ABCs, the composed class must still classify identically, so the
abstract→optional demotion the plan warns against is caught here.

Baseline: ``simbackend_contract_baseline.json`` (currently 28 / 29 / 13). Regenerate only
on an intentional contract change (see ``_dump_baseline`` below).
"""

from __future__ import annotations

import inspect
import json
from pathlib import Path

from unilab.base.backend import base

BASELINE = Path(__file__).resolve().parent / "simbackend_contract_baseline.json"


def classify_public_methods(cls: type) -> dict[str, list[str]]:
"""Classify every public attribute of ``cls`` as abstract / optional / concrete.

Uses ``getattr_static`` (never triggers descriptors) + ``__isabstractmethod__`` +
source inspection for ``raise NotImplementedError``. Deterministic, so generator and
test agree.
"""
out: dict[str, list[str]] = {"abstract": [], "optional": [], "concrete": []}
for name in dir(cls):
if name.startswith("_"):
continue
attr = inspect.getattr_static(cls, name)
if getattr(attr, "__isabstractmethod__", False):
out["abstract"].append(name)
continue
target = attr
if isinstance(attr, property):
target = attr.fget
elif isinstance(attr, (staticmethod, classmethod)):
target = attr.__func__
try:
src = inspect.getsource(target) if target is not None else ""
except (TypeError, OSError):
src = ""
bucket = "optional" if "raise NotImplementedError" in src else "concrete"
out[bucket].append(name)
return {key: sorted(values) for key, values in out.items()}


def _dump_baseline() -> None:
"""Regenerate the baseline after an intentional contract change. Run manually::

uv run python -c "from tests.base.test_simbackend_contract import _dump_baseline; _dump_baseline()"
"""
BASELINE.write_text(json.dumps(classify_public_methods(base.SimBackend), indent=2) + "\n")


def test_simbackend_contract_unchanged():
expected = json.loads(BASELINE.read_text())
current = classify_public_methods(base.SimBackend)
if current == expected:
return
drift = []
for category in ("abstract", "optional", "concrete"):
added = sorted(set(current[category]) - set(expected[category]))
removed = sorted(set(expected[category]) - set(current[category]))
if added or removed:
drift.append(f" {category}: added={added} removed={removed}")
raise AssertionError(
"SimBackend public-method contract drifted from baseline:\n"
+ "\n".join(drift)
+ "\n(If intentional, regenerate via _dump_baseline; do not weaken required abstracts.)"
)
99 changes: 99 additions & 0 deletions tests/config/test_owner_yaml_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Owner-YAML config contract (Phase 1 safety net) + CLI route guard.

Scope note: the full task/backend compose matrix (reward populated, ``training.sim_backend``
matches the owner path) is already covered dynamically by tests/config/test_config_system.py
(``_supported_task_cases`` -> ``test_supported_task_composes``). This file deliberately does
NOT duplicate that; it adds only the contract checks that file does not cover:
- No orphan top-level ``algorithm:`` in any ppo/appo/offpolicy owner YAML (ADR-0003:
algorithm hyperparams live under ``algo:``; train_appo reads ``cfg.algo``, so a top-level
``algorithm:`` is silently dropped to defaults).
- CLI rejects ``training.sim_backend`` as a route-defining override (cli.py
RESERVED_OVERRIDE_KEYS), so backend switching must go through ``task=<task>/<backend>``.

The one known live violation (APPO go2/motrix puts ``algorithm:`` at top level) is excluded
from the scan and captured by the strict xfail below — which flips to a hard suite failure
once Phase 1 Step 1.1 moves the block under ``algo:``, forcing the xfail to be removed.
"""

from __future__ import annotations

from pathlib import Path

import pytest
from omegaconf import OmegaConf

REPO = Path(__file__).resolve().parents[2]

# The single known live orphan-``algorithm:`` violation, excluded from the scan and pinned
# by the strict xfail below (it flips to a suite failure the moment Phase 1 fixes the YAML).
KNOWN_ORPHAN_REL = "appo/task/go2_joystick_flat/motrix.yaml"


def _owner_task_yamls() -> list[Path]:
"""Every ppo/appo/offpolicy owner task YAML, minus the one known-orphan exception."""
paths: list[Path] = []
for algo_dir in ("ppo", "appo", "offpolicy"):
root = REPO / "conf" / algo_dir / "task"
if not root.is_dir():
continue
for path in sorted(root.glob("**/*.yaml")):
if path.relative_to(REPO / "conf").as_posix() == KNOWN_ORPHAN_REL:
continue
paths.append(path)
return paths


def _compose(algo_dir: str, task: str, overrides: tuple[str, ...] = ()):
from hydra import compose, initialize_config_dir

with initialize_config_dir(config_dir=str(REPO / "conf" / algo_dir), version_base="1.3"):
return compose("config", overrides=[f"task={task}", *overrides])


@pytest.mark.parametrize(
"path", _owner_task_yamls(), ids=lambda p: p.relative_to(REPO / "conf").as_posix()
)
def test_owner_yaml_has_no_orphan_top_level_algorithm(path: Path):
"""Raw-file scan: no owner YAML may carry a top-level ``algorithm:`` (ADR-0003).

Complements (does not duplicate) test_config_system's compose matrix — that proves the
composed cfg resolves; this proves owner-file hygiene at the source. The one known
violation is excluded (see KNOWN_ORPHAN_REL) and covered by the strict xfail below.
"""
container = OmegaConf.to_container(OmegaConf.load(path), resolve=False)
assert isinstance(container, dict)
assert "algorithm" not in container, (
f"{path.relative_to(REPO / 'conf')}: orphan top-level 'algorithm:' — algorithm "
"hyperparams belong under 'algo:' (ADR-0003); a top-level block is read by nothing."
)


def test_cli_rejects_sim_backend_passthrough_override():
"""b.md b5: backend switch must go through task=<task>/<backend>, not a bare override.

cli.py puts training.sim_backend in RESERVED_OVERRIDE_KEYS, so build_command must reject
it — bare Hydra compose alone can't prove the CLI guard.
"""
from unilab.cli import build_command

with pytest.raises(SystemExit):
build_command(
mode="train",
algo="ppo",
task="go2_joystick_flat",
sim="mujoco",
overrides=["training.sim_backend=motrix"],
)


@pytest.mark.xfail(
reason="APPO go2/motrix YAML puts algorithm: at top level (orphan); train_appo reads "
"cfg.algo, so num_learning_epochs falls back to the default 5. Phase 1 Step 1.1 moves "
"the block under algo: — this test then XPASSes. strict=True turns that XPASS into a "
"suite failure, forcing this xfail (and KNOWN_ORPHAN_REL) to be removed once fixed.",
strict=True,
)
def test_appo_go2_motrix_algorithm_under_algo():
cfg = _compose("appo", "go2_joystick_flat/motrix")
assert OmegaConf.select(cfg, "algorithm") is None # no orphan top-level algorithm
assert cfg.algo.algorithm.num_learning_epochs == 25 # YAML intent (not default 5)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
94 changes: 94 additions & 0 deletions tests/envs/locomotion/test_reset_plan_golden.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Golden characterization for DR-provider ``build_reset_plan`` (Phase 4.5 safety net).

Pins the *current* ResetPlan output of the Go1/Go2/Go2W rough providers — qpos/qvel
spawn pose, randomization payload (motor gains, friction, mass/COM, gravity, ...),
info_updates key set, and active DR terms — before Phase 4.5 converges their 90%-clone
``build_reset_plan`` methods. Reward golden cannot see any of this.

Also pins the terrain-curriculum *side effect*: build_reset_plan records each episode's
start pose into ``env._spawn`` (``spawn_episode_start_xyz``/``spawn_has_started``), which
update_on_done later reads. The returned ResetPlan hides this, so without the snapshot a
refactor could drop the recording and the qpos/qvel golden would still pass. Go1/Go2 record
it; Go2W's override does not — the generic numeric compare locks whichever is current.

Imports ``snapshot_reset_plan`` from the generator (single source of truth: same env
build + same provider call), so replay matches fixtures exactly. Not marked ``slow``.
"""

from __future__ import annotations

import functools
import sys
from pathlib import Path

import numpy as np
import pytest

_TOOLS = Path(__file__).resolve().parent / "tools"
if str(_TOOLS) not in sys.path:
sys.path.insert(0, str(_TOOLS))
from generate_reset_plan_golden import PROVIDERS, snapshot_reset_plan # noqa: E402

FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures"
_META_KEYS = ("info_keys", "rand_terms") # object string arrays, compared as sets


@functools.lru_cache(maxsize=None)
def _snapshot(task: str, module: str, cls: str):
return snapshot_reset_plan(task, module, cls)


def _fixture_path(fixture: str) -> Path:
return FIXTURE_DIR / f"reset_{fixture}_golden.npz"


def test_reset_plan_fixtures_present():
"""Hard-fail (not silent skip) if any DR fixture is missing from the repo."""
missing = [
f"reset_{name}_golden.npz"
for _, name, _, _ in PROVIDERS
if not _fixture_path(name).exists()
]
assert not missing, (
"Missing DR ResetPlan fixtures: "
+ ", ".join(missing)
+ " — run `uv run python tests/envs/locomotion/tools/generate_reset_plan_golden.py`"
)


@pytest.mark.parametrize("task,fixture,module,cls", PROVIDERS)
def test_reset_plan_unchanged(task: str, fixture: str, module: str, cls: str):
path = _fixture_path(fixture)
if not path.exists():
pytest.skip(f"fixture missing: {path.name} (see test_reset_plan_fixtures_present)")
# Fixtures are produced by the sibling generator and committed in-repo; they contain
# only ndarrays (numeric + unicode str arrays, no pickled objects), so allow_pickle stays off.
golden = np.load(path)
snap = _snapshot(task, module, cls)

# Whole-shape lock: a dropped/added qpos field, info entry, or DR term changes the key set.
assert set(snap) == set(golden.files), (
f"{fixture}: ResetPlan key set changed: {set(snap) ^ set(golden.files)}"
)

# info_updates keys + active DR terms — exact set match.
for meta in _META_KEYS:
if meta in golden.files:
assert sorted(snap[meta].tolist()) == sorted(golden[meta].tolist()), (
f"{fixture}: {meta} changed"
)

# All numeric arrays fully deterministic now: _build_env forces terrain_curriculum.seed,
# so env._spawn picks fixed tiles and absolute spawn (qpos[:,0:3]) is reproducible too
# (previously excluded — the unseeded default_rng(None) made tile choice random per process).
# This now locks the full spawn pose (qpos incl. xy/z), qvel, info_updates, and DR payload.
for key in golden.files:
if key in _META_KEYS:
continue
np.testing.assert_allclose(
np.asarray(snap[key], dtype=np.float64),
golden[key],
rtol=1e-6,
atol=1e-7,
err_msg=f"{fixture} ResetPlan mismatch: {key}",
)
Loading
Loading