Spaces:
Sleeping
Sleeping
File size: 12,450 Bytes
922c4d1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 | """
Unit tests for IncidentOps OpenEnv environment.
Tests verify:
1. All three tasks initialise and return valid observations from reset()
2. step() returns observations with reward values strictly in (0.0, 1.0)
3. Episode lifecycle (reset β step β done)
4. Graders produce deterministic, in-range scores on representative trajectories
5. Simulator handles all valid commands without exceptions
"""
from __future__ import annotations
import sys
import os
# Allow running from repo root without installation
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
from models import IncidentAction, IncidentObservation
from server.environment import IncidentOpsEnvironment
from server.scenarios import ALL_TASK_NAMES, get_scenario
from server.graders import grade, GRADERS
from server.simulation import SimulationEngine
# -----------------------------------------------------------------------------
# Fixtures
# -----------------------------------------------------------------------------
@pytest.fixture
def env() -> IncidentOpsEnvironment:
return IncidentOpsEnvironment()
def _make_action(command: str) -> IncidentAction:
return IncidentAction(command=command)
# -----------------------------------------------------------------------------
# 1. Reset returns valid observations for all tasks
# -----------------------------------------------------------------------------
@pytest.mark.parametrize("task_name", ALL_TASK_NAMES)
def test_reset_returns_observation(env, task_name):
obs = env.reset(task_name=task_name)
assert isinstance(obs, IncidentObservation)
assert isinstance(obs.output, str) and len(obs.output) > 10
assert isinstance(obs.timestamp, str) and "T" in obs.timestamp
assert isinstance(obs.alert_count, int) and obs.alert_count > 0
assert obs.severity in ("critical", "high", "medium", "low", "none")
assert isinstance(obs.affected_services, list)
assert obs.done is False
@pytest.mark.parametrize("task_name", ALL_TASK_NAMES)
def test_reset_reward_in_open_interval(env, task_name):
obs = env.reset(task_name=task_name)
assert obs.reward is not None
assert 0.0 < obs.reward < 1.0, f"Reset reward {obs.reward} not in (0, 1)"
# -----------------------------------------------------------------------------
# 2. step() returns valid observations with reward in (0, 1)
# -----------------------------------------------------------------------------
@pytest.mark.parametrize("task_name", ALL_TASK_NAMES)
def test_step_reward_strictly_in_open_interval(env, task_name):
env.reset(task_name=task_name)
for cmd in ["alerts", "status", "help"]:
obs = env.step(_make_action(cmd))
assert isinstance(obs, IncidentObservation)
assert 0.0 < obs.reward < 1.0, (
f"[{task_name}] reward {obs.reward} for '{cmd}' is not in open interval (0, 1)"
)
assert isinstance(obs.output, str) and len(obs.output) > 0
@pytest.mark.parametrize("task_name", ALL_TASK_NAMES)
def test_step_output_is_non_empty(env, task_name):
env.reset(task_name=task_name)
obs = env.step(_make_action("status"))
assert len(obs.output.strip()) > 0
# -----------------------------------------------------------------------------
# 3. Episode lifecycle β resolve ends the episode
# -----------------------------------------------------------------------------
@pytest.mark.parametrize("task_name", ALL_TASK_NAMES)
def test_resolve_terminates_episode(env, task_name):
env.reset(task_name=task_name)
obs = env.step(_make_action("resolve"))
assert obs.done is True
assert 0.0 < obs.reward < 1.0
def test_step_after_done_returns_done(env):
env.reset(task_name="service-restart")
env.step(_make_action("resolve"))
obs = env.step(_make_action("alerts"))
assert obs.done is True
# -----------------------------------------------------------------------------
# 4. Graders produce deterministic in-range scores
# -----------------------------------------------------------------------------
def test_grade_service_restart_optimal():
"""Agent checks payment-processor then restarts it β should score β₯ 0.7."""
scenario = get_scenario("service-restart", seed=42)
engine = SimulationEngine(scenario)
# Simulate an optimal trajectory
for cmd in ["alerts", "logs payment-processor", "restart payment-processor", "resolve"]:
engine.execute(cmd)
score = grade(scenario, engine, step_count=4)
assert 0.0 < score < 1.0, f"Score {score} not in (0, 1)"
assert score >= 0.70, f"Optimal run score {score:.3f} too low (expected β₯ 0.70)"
def test_grade_service_restart_blind_resolve():
"""Agent immediately resolves without investigating β should score low."""
scenario = get_scenario("service-restart", seed=42)
engine = SimulationEngine(scenario)
engine.execute("resolve")
score = grade(scenario, engine, step_count=1)
assert 0.0 < score < 1.0, f"Score {score} not in (0, 1)"
assert score < 0.35, f"Blind-resolve score {score:.3f} too high (expected < 0.35)"
def test_grade_config_drift_optimal():
"""Agent inspects api-gateway and rolls it back β should score β₯ 0.65."""
scenario = get_scenario("config-drift", seed=99)
engine = SimulationEngine(scenario)
for cmd in [
"alerts", "status",
"logs api-gateway", "metrics api-gateway",
"diagnose api-gateway",
"rollback api-gateway",
"notify oncall resolved",
"resolve",
]:
engine.execute(cmd)
score = grade(scenario, engine, step_count=8)
assert 0.0 < score < 1.0, f"Score {score} not in (0, 1)"
assert score >= 0.65, f"Config-drift optimal score {score:.3f} too low (expected β₯ 0.65)"
def test_grade_cascading_failure_full_remediation():
"""Agent does DB failover + cache restart β should score β₯ 0.60."""
scenario = get_scenario("cascading-failure", seed=7)
engine = SimulationEngine(scenario)
for cmd in [
"alerts", "status",
"logs api-gateway",
"logs cache-layer", "metrics cache-layer",
"logs database-primary", "metrics database-primary",
"diagnose database-primary",
"failover database-primary",
"restart cache-layer",
"notify oncall failover-complete",
"resolve",
]:
engine.execute(cmd)
score = grade(scenario, engine, step_count=12)
assert 0.0 < score < 1.0, f"Score {score} not in (0, 1)"
assert score >= 0.60, f"Cascading-failure score {score:.3f} too low (expected β₯ 0.60)"
def test_all_graders_clamp_scores():
"""No grader should produce 0.0 or 1.0 exactly."""
for task_name in ALL_TASK_NAMES:
scenario = get_scenario(task_name, seed=1)
engine = SimulationEngine(scenario)
score = grade(scenario, engine, step_count=1)
assert 0.0 < score < 1.0, (
f"[{task_name}] score {score} not strictly in (0, 1)"
)
def test_graders_deterministic():
"""Running grade twice on same state should return the same score."""
for task_name in ALL_TASK_NAMES:
scenario1 = get_scenario(task_name, seed=42)
engine1 = SimulationEngine(scenario1)
for cmd in ["alerts", "status"]:
engine1.execute(cmd)
score_a = grade(scenario1, engine1, step_count=2)
scenario2 = get_scenario(task_name, seed=42)
engine2 = SimulationEngine(scenario2)
for cmd in ["alerts", "status"]:
engine2.execute(cmd)
score_b = grade(scenario2, engine2, step_count=2)
assert score_a == score_b, (
f"[{task_name}] grader not deterministic: {score_a:.4f} != {score_b:.4f}"
)
# -----------------------------------------------------------------------------
# 5. Simulator handles all commands without exceptions
# -----------------------------------------------------------------------------
@pytest.mark.parametrize("task_name", ALL_TASK_NAMES)
def test_all_commands_execute_without_exception(env, task_name):
env.reset(task_name=task_name)
scenario = get_scenario(task_name)
services = list(scenario.services.keys())
req_ids = scenario.request_ids
commands_to_test = [
"help", "status", "alerts",
f"logs {services[0]}",
f"metrics {services[0]}",
f"diagnose {services[0]}",
f"trace {req_ids[0]}",
f"scale {services[0]} 3",
"notify oncall investigating",
]
for cmd in commands_to_test:
obs = env.step(_make_action(cmd))
assert isinstance(obs, IncidentObservation), f"Non-observation for '{cmd}'"
assert 0.0 < obs.reward < 1.0, f"Reward {obs.reward} out of range for '{cmd}'"
def test_unknown_command_does_not_crash(env):
env.reset(task_name="service-restart")
obs = env.step(_make_action("nonexistent_op"))
assert isinstance(obs, IncidentObservation)
assert "unknown" in obs.output.lower() or "not found" in obs.output.lower()
def test_step_without_service_arg(env):
"""Commands missing required args should return usage hint, not crash."""
env.reset(task_name="service-restart")
for cmd in ["logs", "metrics", "diagnose", "restart", "rollback", "failover"]:
obs = env.step(_make_action(cmd))
assert isinstance(obs, IncidentObservation)
assert obs.reward is not None
assert 0.0 < obs.reward < 1.0, f"Reward {obs.reward} out of range for '{cmd}'"
# -----------------------------------------------------------------------------
# 6. Scenario data integrity
# -----------------------------------------------------------------------------
@pytest.mark.parametrize("task_name", ALL_TASK_NAMES)
def test_scenario_has_minimum_structure(task_name):
scenario = get_scenario(task_name)
assert len(scenario.services) >= 3, "Scenario must have β₯ 3 services"
assert len(scenario.alerts) >= 1, "Scenario must have β₯ 1 alert"
assert scenario.root_cause_service in scenario.services
assert scenario.max_steps >= 10
assert scenario.difficulty in ("easy", "medium", "hard")
assert len(scenario.request_ids) >= 1
@pytest.mark.parametrize("task_name", ALL_TASK_NAMES)
def test_scenario_reproducible_with_same_seed(task_name):
s1 = get_scenario(task_name, seed=123)
s2 = get_scenario(task_name, seed=123)
assert s1.name == s2.name
assert s1.root_cause_service == s2.root_cause_service
assert len(s1.alerts) == len(s2.alerts)
# -----------------------------------------------------------------------------
# 7. Model-level reward clamping
# -----------------------------------------------------------------------------
def test_model_validator_clamps_zero():
"""reward=0.0 should be clamped to 0.01."""
obs = IncidentObservation(
output="test", timestamp="2026-01-01T00:00:00Z",
done=False, reward=0.0,
)
assert obs.reward == 0.01
def test_model_validator_clamps_one():
"""reward=1.0 should be clamped to 0.99."""
obs = IncidentObservation(
output="test", timestamp="2026-01-01T00:00:00Z",
done=False, reward=1.0,
)
assert obs.reward == 0.99
def test_model_validator_clamps_negative():
"""Negative reward should be clamped to 0.01."""
obs = IncidentObservation(
output="test", timestamp="2026-01-01T00:00:00Z",
done=False, reward=-5.0,
)
assert obs.reward == 0.01
def test_model_validator_preserves_valid():
"""Valid reward should pass through unchanged."""
obs = IncidentObservation(
output="test", timestamp="2026-01-01T00:00:00Z",
done=False, reward=0.42,
)
assert obs.reward == 0.42
def test_model_validator_none_stays_none():
"""None reward should remain None."""
obs = IncidentObservation(
output="test", timestamp="2026-01-01T00:00:00Z",
done=False, reward=None,
)
assert obs.reward is None
def test_model_validator_bool_true():
"""bool True (== 1) should be clamped to 0.99."""
obs = IncidentObservation(
output="test", timestamp="2026-01-01T00:00:00Z",
done=False, reward=True,
)
assert obs.reward == 0.99
def test_model_validator_bool_false():
"""bool False (== 0) should be clamped to 0.01."""
obs = IncidentObservation(
output="test", timestamp="2026-01-01T00:00:00Z",
done=False, reward=False,
)
assert obs.reward == 0.01
|