"""Tests for metrics manager functionality.""" from unittest.mock import Mock import pytest import torch from mjlab.managers.metrics_manager import ( MetricsManager, MetricsTermCfg, NullMetricsManager, ) class SimpleTestMetric: """A class-based metric that tracks state.""" def __init__(self, cfg: MetricsTermCfg, env): self.call_count = torch.zeros(env.num_envs, device=env.device) def __call__(self, env, **kwargs): self.call_count += 1 return torch.ones(env.num_envs, device=env.device) * 0.5 def reset(self, env_ids: torch.Tensor | None = None, env=None): if env_ids is not None and len(env_ids) > 0: self.call_count[env_ids] = 0 @pytest.fixture def mock_env(): env = Mock() env.num_envs = 4 env.device = "cpu" env.scene = {"robot": Mock()} return env def test_episode_averages_and_reset(mock_env): """Compute for N steps, reset a subset, verify averages and zeroing.""" cfg = { "term": MetricsTermCfg( func=lambda env: torch.ones(env.num_envs, device=env.device) * 0.5, params={}, ) } manager = MetricsManager(cfg, mock_env) for _ in range(10): manager.compute() info = manager.reset(env_ids=torch.tensor([0, 1])) # Each env: sum=5.0, count=10, avg=0.5. Mean across 2 reset envs = 0.5. assert info["Episode_Metrics/term"].item() == pytest.approx(0.5) # Reset envs zeroed; non-reset envs untouched. assert manager._episode_sums["term"][0] == 0.0 assert manager._step_count[0] == 0 assert manager._episode_sums["term"][2] == pytest.approx(5.0) assert manager._step_count[2] == 10 def test_early_termination_uses_per_env_step_count(mock_env): """Envs with different episode lengths get correct per-step averages.""" step = [0] def step_dependent_metric(env): step[0] += 1 return torch.full((env.num_envs,), float(step[0]), device=env.device) cfg = {"m": MetricsTermCfg(func=step_dependent_metric, params={})} manager = MetricsManager(cfg, mock_env) # 4 steps for all envs: values are 1, 2, 3, 4. for _ in range(4): manager.compute() # Env 0: sum=10, count=4. Reset it (env 1 keeps accumulating). manager.reset(env_ids=torch.tensor([0])) # 2 more steps: values are 5, 6. for _ in range(2): manager.compute() # Env 0: sum=11, count=2, avg=5.5. # Env 1: sum=21, count=6, avg=3.5. info = manager.reset(env_ids=torch.tensor([0, 1])) # Mean of [5.5, 3.5] = 4.5. assert info["Episode_Metrics/m"].item() == pytest.approx(4.5) def test_class_based_metric_reset_targets_correct_envs(mock_env): """Class-based term's reset() is called with the correct env_ids.""" cfg = {"term": MetricsTermCfg(func=SimpleTestMetric, params={})} manager = MetricsManager(cfg, mock_env) term = manager._class_term_cfgs[0].func for _ in range(10): manager.compute() manager.reset(env_ids=torch.tensor([0, 2])) assert term.call_count[0] == 0 assert term.call_count[1] == 10 assert term.call_count[2] == 0 assert term.call_count[3] == 10 def test_null_metrics_manager(mock_env): """NullMetricsManager doesn't crash and returns empty dict on reset.""" manager = NullMetricsManager() manager.compute() assert manager.reset(env_ids=torch.tensor([0])) == {} def test_none_terms_are_skipped(mock_env): """None terms in config are skipped without error.""" cfg: dict[str, MetricsTermCfg | None] = { "valid": MetricsTermCfg( func=lambda env: torch.ones(env.num_envs, device=env.device), params={}, ), "skipped": None, } manager = MetricsManager(cfg, mock_env) # type: ignore[arg-type] assert manager._term_names == ["valid"] def test_per_substep_averaging(mock_env): """Substep terms are averaged across substeps; step terms evaluated once.""" substep_call = [0] def rising_metric(env): substep_call[0] += 1 return torch.full((env.num_envs,), float(substep_call[0]), device=env.device) cfg = { "step_term": MetricsTermCfg( func=lambda env: torch.ones(env.num_envs, device=env.device) * 2.0, params={}, ), "substep_term": MetricsTermCfg(func=rising_metric, params={}, per_substep=True), } manager = MetricsManager(cfg, mock_env) for _ in range(2): # 2 env steps for _ in range(4): # 4 substeps each manager.compute_substep() manager.compute() info = manager.reset(env_ids=torch.tensor([0])) assert info["Episode_Metrics/step_term"].item() == pytest.approx(2.0) # Substeps: step 0 -> [1,2,3,4] avg=2.5; step 1 -> [5,6,7,8] avg=6.5. # Episode avg = (2.5 + 6.5) / 2 = 4.5. A broken single-eval path # would give (1 + 2) / 2 = 1.5. assert info["Episode_Metrics/substep_term"].item() == pytest.approx(4.5) def test_substep_reset_zeroes_accumulators(mock_env): """Reset zeroes substep accumulators for the given env_ids.""" cfg = { "sub": MetricsTermCfg( func=lambda env: torch.ones(env.num_envs, device=env.device), params={}, per_substep=True, ), } manager = MetricsManager(cfg, mock_env) # Accumulate some substep values without calling compute(). for _ in range(3): manager.compute_substep() # Reset env 0 -- its substep accum should be zeroed. manager.reset(env_ids=torch.tensor([0])) assert manager._substep_accum[0][0].item() == 0.0 # Env 1 still has its accumulated value. assert manager._substep_accum[0][1].item() == pytest.approx(3.0) def test_reduce_last_reports_final_step_value(mock_env): """reduce='last' reports the last step's value, not the episode average.""" step = [0] def rising_metric(env): step[0] += 1 return torch.full((env.num_envs,), float(step[0]), device=env.device) cfg = {"term": MetricsTermCfg(func=rising_metric, params={}, reduce="last")} manager = MetricsManager(cfg, mock_env) for _ in range(4): manager.compute() info = manager.reset(env_ids=torch.tensor([0])) # Values were 1, 2, 3, 4. Mean would be 2.5; last should be 4. assert info["Episode_Metrics/term"].item() == pytest.approx(4.0) def test_reduce_mean_and_last_coexist(mock_env): """Mixed reduce modes in the same manager report correctly.""" step = [0] def rising_metric(env): step[0] += 1 return torch.full((env.num_envs,), float(step[0]), device=env.device) cfg = { "mean_term": MetricsTermCfg(func=rising_metric, params={}, reduce="mean"), "last_term": MetricsTermCfg(func=rising_metric, params={}, reduce="last"), } manager = MetricsManager(cfg, mock_env) for _ in range(3): manager.compute() info = manager.reset(env_ids=torch.tensor([0])) # mean_term sees steps 1, 3, 5 (odd calls); episode avg = 3.0. # last_term sees steps 2, 4, 6 (even calls); last value = 6.0. assert info["Episode_Metrics/mean_term"].item() == pytest.approx(3.0) assert info["Episode_Metrics/last_term"].item() == pytest.approx(6.0) def test_metrics_step_shape_validation_rejects_bad_compute_output(mock_env): """Step metrics must return one scalar per environment.""" cfg = {"bad": MetricsTermCfg(func=lambda env: torch.ones(env.num_envs, 1))} manager = MetricsManager(cfg, mock_env) with pytest.raises(ValueError, match="MetricsManager term 'bad'.*expected \\(4,\\)"): manager.compute() def test_metrics_substep_shape_validation_rejects_bad_compute_output(mock_env): """Substep metrics must return one scalar per environment.""" cfg = { "bad": MetricsTermCfg( func=lambda env: torch.ones(env.num_envs, 1), per_substep=True ) } manager = MetricsManager(cfg, mock_env) with pytest.raises(ValueError, match="MetricsManager term 'bad'.*expected \\(4,\\)"): manager.compute_substep() def test_reduce_max_reports_episode_peak(mock_env): """reduce='max' reports the highest value seen during the episode.""" step = [0] def rising_then_falling(env): step[0] += 1 val = 5.0 - abs(step[0] - 3) # values: 3, 4, 5, 4, 3 return torch.full((env.num_envs,), val, device=env.device) cfg = {"term": MetricsTermCfg(func=rising_then_falling, params={}, reduce="max")} manager = MetricsManager(cfg, mock_env) for _ in range(5): manager.compute() info = manager.reset(env_ids=torch.tensor([0])) assert info["Episode_Metrics/term"].item() == pytest.approx(5.0) def test_reduce_max_reset_clears_to_neg_inf(mock_env): """After reset, max tracking restarts from -inf.""" cfg = { "term": MetricsTermCfg( func=lambda env: torch.ones(env.num_envs, device=env.device) * 10.0, params={}, reduce="max", ) } manager = MetricsManager(cfg, mock_env) manager.compute() manager.reset(env_ids=torch.tensor([0])) # After reset, the max buffer for env 0 should be -inf. assert manager._episode_max["term"][0].item() == float("-inf") # Env 1 was not reset, so it keeps its max. assert manager._episode_max["term"][1].item() == pytest.approx(10.0) def test_reduce_max_coexists_with_mean_and_last(mock_env): """All three reduce modes work correctly in the same manager.""" step = [0] def rising_metric(env): step[0] += 1 return torch.full((env.num_envs,), float(step[0]), device=env.device) cfg = { "mean_term": MetricsTermCfg(func=rising_metric, params={}, reduce="mean"), "last_term": MetricsTermCfg(func=rising_metric, params={}, reduce="last"), "max_term": MetricsTermCfg(func=rising_metric, params={}, reduce="max"), } manager = MetricsManager(cfg, mock_env) for _ in range(3): manager.compute() info = manager.reset(env_ids=torch.tensor([0])) # mean_term sees 1, 4, 7; avg = 4.0 assert info["Episode_Metrics/mean_term"].item() == pytest.approx(4.0) # last_term sees 2, 5, 8; last = 8.0 assert info["Episode_Metrics/last_term"].item() == pytest.approx(8.0) # max_term sees 3, 6, 9; max = 9.0 assert info["Episode_Metrics/max_term"].item() == pytest.approx(9.0) def test_reduce_max_with_per_substep(mock_env): """reduce='max' with per_substep tracks the max of step-averaged values.""" substep_call = [0] def rising_metric(env): substep_call[0] += 1 return torch.full((env.num_envs,), float(substep_call[0]), device=env.device) cfg = { "sub_max": MetricsTermCfg( func=rising_metric, params={}, per_substep=True, reduce="max" ), } manager = MetricsManager(cfg, mock_env) # Step 0: substeps [1, 2] -> avg = 1.5 for _ in range(2): manager.compute_substep() manager.compute() # Step 1: substeps [3, 4] -> avg = 3.5 for _ in range(2): manager.compute_substep() manager.compute() info = manager.reset(env_ids=torch.tensor([0])) # Max of step averages: max(1.5, 3.5) = 3.5 assert info["Episode_Metrics/sub_max"].item() == pytest.approx(3.5) def test_no_substep_terms_no_overhead(mock_env): """When no per_substep terms exist, compute_substep is a no-op.""" cfg = { "step_only": MetricsTermCfg( func=lambda env: torch.ones(env.num_envs, device=env.device), params={}, ), } manager = MetricsManager(cfg, mock_env) # Should not raise or change state. manager.compute_substep() assert manager._substep_count == 0 manager.compute() info = manager.reset(env_ids=torch.tensor([0])) assert info["Episode_Metrics/step_only"].item() == pytest.approx(1.0)