File size: 22,040 Bytes
e9ce6e9 | 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 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 | """
tests/test_step.py
==================
Phase 4 rewrite β 24 tests covering every CLAUDE.md step() contract point.
"""
import pytest
from pydantic import ValidationError
from unified_gateway import AEPOAction, AEPOObservation, UFRGObservation, UFRGReward, UnifiedFintechEnv
# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def env() -> UnifiedFintechEnv:
e = UnifiedFintechEnv()
e.reset(options={"task": "easy"})
return e
def make_action(**kwargs) -> AEPOAction:
"""Build an AEPOAction with safe defaults for unspecified fields."""
defaults = dict(risk_decision=0, crypto_verify=0, infra_routing=0,
db_retry_policy=0, settlement_policy=0, app_priority=2)
defaults.update(kwargs)
return AEPOAction(**defaults)
def _force_obs(env: UnifiedFintechEnv, **fields) -> None:
"""Overwrite env._current_obs with specific field values."""
current = env._current_obs.model_dump()
current.update(fields)
env._current_obs = AEPOObservation.model_construct(**current)
# ---------------------------------------------------------------------------
# Test 1 β step() returns a 4-tuple
# ---------------------------------------------------------------------------
def test_step_returns_4_tuple(env: UnifiedFintechEnv) -> None:
"""step() must return exactly (obs, reward, done, info)."""
result = env.step(make_action())
assert len(result) == 4
# ---------------------------------------------------------------------------
# Test 2 β reward always in [0.0, 1.0]
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("task", ["easy", "medium", "hard"])
def test_reward_always_in_range(task: str) -> None:
"""reward.value must never leave [0.0, 1.0] across all tasks."""
e = UnifiedFintechEnv()
e.reset(options={"task": task})
for _ in range(10):
_, tr, _, _ = e.step(make_action())
assert 0.0 <= tr.value <= 1.0, f"reward={tr.value} for task={task}"
# ---------------------------------------------------------------------------
# Test 3 β done=True when kafka_lag > 4000
# ---------------------------------------------------------------------------
def test_done_true_on_kafka_lag_crash(env: UnifiedFintechEnv) -> None:
"""kafka_lag > CRASH_THRESHOLD (4000) for 2 consecutive steps must set done=True.
Fix 11.1: single-step grace period β one spike is not a crash, sustained overload is.
"""
_force_obs(env, kafka_lag=4500.0)
env._rolling_lag = 0.0
# Step 1: lag > 4000 β grace (streak=1), episode continues
_, _, done_step1, _ = env.step(make_action())
assert done_step1 is False, "Grace step should not terminate episode"
# Step 2: lag still > 4000 β streak=2, crash fires
_force_obs(env, kafka_lag=4500.0)
_, tr, done, info = env.step(make_action())
assert done is True
assert tr.value == 0.0
assert info["termination_reason"] == "crash"
# ---------------------------------------------------------------------------
# Test 4 β done=True on catastrophic fraud
# ---------------------------------------------------------------------------
def test_done_true_on_fraud_catastrophe(env: UnifiedFintechEnv) -> None:
"""Approve + SkipVerify + risk_score > 80 β reward=0.0, done=True."""
_force_obs(env, risk_score=90.0)
_, tr, done, info = env.step(make_action(risk_decision=0, crypto_verify=1))
assert done is True
assert tr.value == 0.0
assert info["termination_reason"] == "fraud"
# ---------------------------------------------------------------------------
# Test 5 β done=False before step 100 on easy with valid actions
# ---------------------------------------------------------------------------
def test_done_false_before_step_100_easy() -> None:
"""99 valid steps on easy must not set done=True."""
e = UnifiedFintechEnv()
e.reset(options={"task": "easy"})
for i in range(99):
_, _, done, _ = e.step(make_action(risk_decision=1, crypto_verify=1, infra_routing=1))
assert done is False, f"done=True unexpectedly at step {i+1}"
# ---------------------------------------------------------------------------
# Test 6 β done=True after exactly 100 steps
# ---------------------------------------------------------------------------
def test_done_true_after_100_steps() -> None:
"""Episode must terminate at exactly 100 steps."""
e = UnifiedFintechEnv()
e.reset(options={"task": "easy"})
done = False
for _ in range(100):
_, _, done, _ = e.step(make_action(risk_decision=1, crypto_verify=1, infra_routing=1))
assert done is True
# ---------------------------------------------------------------------------
# Test 7 β catastrophic fraud sets reward=0.0
# ---------------------------------------------------------------------------
def test_fraud_catastrophe_reward_is_zero(env: UnifiedFintechEnv) -> None:
"""Fraud catastrophe must produce reward=0.0, not just low reward."""
_force_obs(env, risk_score=95.0)
_, tr, _, _ = env.step(make_action(risk_decision=0, crypto_verify=1))
assert tr.value == 0.0
# ---------------------------------------------------------------------------
# Test 8 β system crash sets reward=0.0
# ---------------------------------------------------------------------------
def test_crash_reward_is_zero(env: UnifiedFintechEnv) -> None:
"""kafka_lag > 4000 for 2 consecutive steps must produce reward=0.0.
Fix 11.1: grace period means the crash fires on the second sustained step.
"""
_force_obs(env, kafka_lag=4001.0)
env._rolling_lag = 0.0
# Step 1: grace β reward is NOT 0.0 yet
env.step(make_action())
# Step 2: crash fires β reward must be 0.0
_force_obs(env, kafka_lag=4001.0)
_, tr, done, _ = env.step(make_action())
assert done is True
assert tr.value == 0.0
# ---------------------------------------------------------------------------
# Test 9 β SLA breach applies -0.30 penalty
# ---------------------------------------------------------------------------
def test_sla_breach_applies_penalty(env: UnifiedFintechEnv) -> None:
"""rolling_p99 > 800 on a clean step should yield reward β 0.5 (0.8 - 0.30)."""
_force_obs(env, kafka_lag=0.0, rolling_p99=1000.0, risk_score=10.0)
env._rolling_lag = 0.0
env._rolling_p99 = 1000.0
_, _, _, info = env.step(make_action())
assert info["reward_breakdown"]["sla_penalty"] == -0.30
assert abs(info["reward_breakdown"]["final"] - 0.5) < 0.15
# ---------------------------------------------------------------------------
# Test 10 β Challenge on high-risk applies +0.05 bonus
# ---------------------------------------------------------------------------
def test_challenge_bonus_on_high_risk(env: UnifiedFintechEnv) -> None:
"""Challenge (risk_decision=2) on risk_score > 80 β bonus += 0.05."""
_force_obs(env, risk_score=85.0, kafka_lag=0.0, rolling_p99=0.0)
env._rolling_lag = 0.0
_, _, _, info = env.step(make_action(risk_decision=2, crypto_verify=0))
assert info["reward_breakdown"]["bonus"] >= 0.05
# ---------------------------------------------------------------------------
# Test 11 β Reject+SkipVerify on high-risk applies +0.04 bonus (blind spot)
# ---------------------------------------------------------------------------
def test_reject_skip_verify_blind_spot_bonus(env: UnifiedFintechEnv) -> None:
"""Reject + SkipVerify on risk > 80 must trigger blind_spot_triggered=True and +0.04."""
_force_obs(env, risk_score=90.0, kafka_lag=0.0, rolling_p99=0.0)
env._rolling_lag = 0.0
_, _, _, info = env.step(make_action(risk_decision=1, crypto_verify=1))
assert info["blind_spot_triggered"] is True
assert info["reward_breakdown"]["bonus"] >= 0.04
# ---------------------------------------------------------------------------
# Test 12 β CircuitBreaker applies -0.50 penalty
# ---------------------------------------------------------------------------
def test_circuit_breaker_penalty(env: UnifiedFintechEnv) -> None:
"""CircuitBreaker (infra_routing=2) must apply -0.50 infra_penalty."""
_force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0)
env._rolling_lag = 0.0
_, _, _, info = env.step(make_action(infra_routing=2))
assert info["reward_breakdown"]["infra_penalty"] <= -0.50
assert info["circuit_breaker_tripped"] is True
# ---------------------------------------------------------------------------
# Test 13 β DeferredAsync during Normal phase applies -0.15 penalty
# ---------------------------------------------------------------------------
def test_deferred_async_normal_phase_penalty(env: UnifiedFintechEnv) -> None:
"""DeferredAsyncFallback (settlement_policy=1) in Normal phase β -0.15."""
# Force normal conditions: easy task, healthy bank
_force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, bank_api_status=0.0)
env._rolling_lag = 0.0
env._last_event_type = "normal"
_, _, _, info = env.step(make_action(settlement_policy=1))
assert info["reward_breakdown"]["settlement_penalty"] <= -0.15
# ---------------------------------------------------------------------------
# Test 14 β DeferredAsync cumulative > 10 applies extra -0.20
# ---------------------------------------------------------------------------
def test_deferred_async_cumulative_penalty(env: UnifiedFintechEnv) -> None:
"""Cumulative settlement backlog > 10 triggers -0.20 additional penalty."""
env._cumulative_settlement_backlog = 10 # already at 10; next step makes it 11
_force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, bank_api_status=0.0)
env._rolling_lag = 0.0
env._last_event_type = "normal"
_, _, _, info = env.step(make_action(settlement_policy=1))
assert info["consecutive_deferred_async"] == 11
# Both -0.15 (normal phase) and -0.20 (> 10 cumulative) should be applied
assert info["reward_breakdown"]["settlement_penalty"] <= -0.35
# ---------------------------------------------------------------------------
# Test 15 β ExponentialBackoff when db_pool < 20 applies -0.10
# ---------------------------------------------------------------------------
def test_exponential_backoff_low_pool_penalty(env: UnifiedFintechEnv) -> None:
"""ExponentialBackoff (db_retry_policy=1) when db_pool < 20 β -0.10."""
_force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, db_connection_pool=10.0)
env._rolling_lag = 0.0
_, _, _, info = env.step(make_action(db_retry_policy=1))
assert info["reward_breakdown"]["db_penalty"] == -0.10
# ---------------------------------------------------------------------------
# Test 16 β ExponentialBackoff when db_pool > 80 applies +0.03 bonus
# ---------------------------------------------------------------------------
def test_exponential_backoff_high_pool_bonus(env: UnifiedFintechEnv) -> None:
"""ExponentialBackoff (db_retry_policy=1) when db_pool > 80 β +0.03 db_penalty."""
_force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, db_connection_pool=85.0)
env._rolling_lag = 0.0
_, _, _, info = env.step(make_action(db_retry_policy=1))
assert info["reward_breakdown"]["db_penalty"] == 0.03
# ---------------------------------------------------------------------------
# Test 17 β app_priority=UPI with merchant_tier=Small β +0.02
# ---------------------------------------------------------------------------
def test_app_priority_upi_small_merchant_bonus(env: UnifiedFintechEnv) -> None:
"""UPI priority (app_priority=0) + Small merchant (tier=0) β +0.02 bonus."""
_force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, merchant_tier=0.0)
env._rolling_lag = 0.0
_, _, _, info = env.step(make_action(app_priority=0))
assert info["reward_breakdown"]["bonus"] >= 0.02
# ---------------------------------------------------------------------------
# Test 18 β app_priority=Credit with merchant_tier=Enterprise β +0.02
# ---------------------------------------------------------------------------
def test_app_priority_credit_enterprise_bonus() -> None:
"""Credit priority (app_priority=1) + Enterprise merchant (tier=1) β +0.02 bonus."""
e = UnifiedFintechEnv()
e.reset(options={"task": "hard"}) # hard task sets merchant_tier=Enterprise=1.0
obs = e._current_obs
# Only proceed if the hard obs actually set Enterprise tier
_force_obs(e, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, merchant_tier=1.0)
e._rolling_lag = 0.0
_, _, _, info = e.step(make_action(app_priority=1))
assert info["reward_breakdown"]["bonus"] >= 0.02
# ---------------------------------------------------------------------------
# Test 19 β info dict contains all required keys on every step
# ---------------------------------------------------------------------------
def test_info_dict_contains_all_required_keys(env: UnifiedFintechEnv) -> None:
"""Every step() must return an info dict with the full CLAUDE.md key set."""
_, _, _, info = env.step(make_action())
required_keys = {
"phase", "curriculum_level", "step_in_episode", "raw_obs",
"reward_breakdown", "termination_reason", "adversary_threat_level_raw",
"blind_spot_triggered", "consecutive_deferred_async",
# backward-compat keys
"step", "task", "event_type", "obs_risk_score", "obs_kafka_lag",
"obs_rolling_p99", "action_risk_decision", "action_infra_routing",
"action_crypto_verify", "reward_raw", "reward_final",
"circuit_breaker_tripped", "crashed", "done",
"internal_rolling_lag", "internal_rolling_latency",
}
missing = required_keys - info.keys()
assert not missing, f"Missing info keys: {missing}"
# ---------------------------------------------------------------------------
# Test 20 β info["reward_breakdown"]["final"] matches returned reward
# ---------------------------------------------------------------------------
def test_reward_breakdown_final_matches_return(env: UnifiedFintechEnv) -> None:
"""reward_breakdown['final'] must equal the typed_reward.value returned."""
_, typed_reward, _, info = env.step(make_action())
assert info["reward_breakdown"]["final"] == typed_reward.value
# ---------------------------------------------------------------------------
# Test 21 β blind_spot_triggered=True on Reject+SkipVerify+high_risk
# ---------------------------------------------------------------------------
def test_blind_spot_triggered_flag(env: UnifiedFintechEnv) -> None:
"""blind_spot_triggered must be True for Reject+SkipVerify on risk > 80."""
_force_obs(env, risk_score=88.0)
_, _, _, info = env.step(make_action(risk_decision=1, crypto_verify=1))
assert info["blind_spot_triggered"] is True
# ---------------------------------------------------------------------------
# Test 22 β termination_reason="crash" on lag crash
# ---------------------------------------------------------------------------
def test_termination_reason_crash(env: UnifiedFintechEnv) -> None:
"""kafka_lag > 4000 for 2 consecutive steps β info['termination_reason'] = 'crash'.
Fix 11.1: single spike gives streak=1 (grace), second spike fires the crash.
"""
_force_obs(env, kafka_lag=5000.0)
env._rolling_lag = 0.0
# Step 1: grace β termination_reason is None
_, _, _, info1 = env.step(make_action())
assert info1["termination_reason"] is None, "Grace step should not crash"
assert info1["crash_grace_active"] is True
# Step 2: sustained overload β crash fires
_force_obs(env, kafka_lag=5000.0)
_, _, done, info = env.step(make_action())
assert done is True
assert info["termination_reason"] == "crash"
# ---------------------------------------------------------------------------
# Test 23 β termination_reason="fraud" on fraud catastrophe
# ---------------------------------------------------------------------------
def test_termination_reason_fraud(env: UnifiedFintechEnv) -> None:
"""Fraud catastrophe must set info['termination_reason'] = 'fraud'."""
_force_obs(env, risk_score=92.0)
_, _, _, info = env.step(make_action(risk_decision=0, crypto_verify=1))
assert info["termination_reason"] == "fraud"
# ---------------------------------------------------------------------------
# Test 24 β termination_reason=None on a normal step
# ---------------------------------------------------------------------------
def test_termination_reason_none_on_normal_step(env: UnifiedFintechEnv) -> None:
"""A normal step must leave info['termination_reason'] as None."""
_force_obs(env, kafka_lag=100.0, rolling_p99=50.0, risk_score=10.0)
env._rolling_lag = 0.0
_, _, _, info = env.step(make_action(risk_decision=1, crypto_verify=0))
assert info["termination_reason"] is None
# ---------------------------------------------------------------------------
# Test 25 β reject spam penalty (Issue 6 fix)
# ---------------------------------------------------------------------------
def test_reject_spam_penalty_fires_after_5_consecutive_rejects(env: UnifiedFintechEnv) -> None:
"""6+ consecutive Reject decisions must trigger -0.15 infra_penalty (reject_spam_active)."""
_force_obs(env, kafka_lag=50.0, rolling_p99=50.0, risk_score=30.0)
env._rolling_lag = 0.0
env._rolling_p99 = 50.0
# Step 6 times with Reject β reject_spam fires on step 6 (counter > 5)
for _ in range(5):
env.step(make_action(risk_decision=1))
_, _, _, info = env.step(make_action(risk_decision=1))
assert info["reject_spam_active"] is True
assert info["reward_breakdown"]["infra_penalty"] <= -0.15, (
"reject_spam_active must debit -0.15 under infra_penalty"
)
def test_reject_spam_resets_on_non_reject(env: UnifiedFintechEnv) -> None:
"""A single non-Reject decision must clear the consecutive_rejects counter."""
_force_obs(env, kafka_lag=50.0, rolling_p99=50.0, risk_score=30.0)
env._rolling_lag = 0.0
for _ in range(6):
env.step(make_action(risk_decision=1))
# Approve once β resets streak
_force_obs(env, kafka_lag=50.0, rolling_p99=50.0, risk_score=30.0)
_, _, _, info = env.step(make_action(risk_decision=0))
assert info["reject_spam_active"] is False
assert info["consecutive_rejects"] == 0
# ---------------------------------------------------------------------------
# Test 26 β throughput bonus (Issue 6 fix)
# ---------------------------------------------------------------------------
def test_throughput_bonus_fires_on_approve_low_risk_healthy_lag(env: UnifiedFintechEnv) -> None:
"""Approve + risk_score < 40 + kafka_lag < 1200 must award +0.03 throughput bonus."""
_force_obs(env, kafka_lag=100.0, rolling_p99=50.0, risk_score=20.0)
env._rolling_lag = 0.0
env._rolling_p99 = 50.0
env._kafka_lag = 100.0
_, _, _, info = env.step(make_action(risk_decision=0)) # Approve
assert info["throughput_bonus_active"] is True
assert info["reward_breakdown"]["bonus"] >= 0.03, (
f"Expected throughput bonus β₯ 0.03, got {info['reward_breakdown']['bonus']}"
)
def test_throughput_bonus_does_not_fire_on_high_risk(env: UnifiedFintechEnv) -> None:
"""Approve + risk_score β₯ 40 must NOT trigger throughput bonus."""
_force_obs(env, kafka_lag=100.0, rolling_p99=50.0, risk_score=50.0)
env._rolling_lag = 0.0
env._kafka_lag = 100.0
_, _, _, info = env.step(make_action(risk_decision=0)) # Approve high-risk
assert info["throughput_bonus_active"] is False
def test_throughput_bonus_does_not_fire_on_high_lag(env: UnifiedFintechEnv) -> None:
"""Approve + risk_score < 40 but kafka_lag β₯ 1200 must NOT trigger throughput bonus."""
_force_obs(env, kafka_lag=1500.0, rolling_p99=50.0, risk_score=20.0)
env._rolling_lag = 0.0
env._kafka_lag = 1500.0
_, _, _, info = env.step(make_action(risk_decision=0))
assert info["throughput_bonus_active"] is False
# ---------------------------------------------------------------------------
# Test 27 β CircuitBreaker half-open mode (uncovered lines 1405-1411)
# ---------------------------------------------------------------------------
def test_circuit_breaker_enters_half_open_after_cb_half_open_after_steps(env: UnifiedFintechEnv) -> None:
"""After CB_HALF_OPEN_AFTER consecutive CircuitBreaker steps, CB enters half-open mode.
Half-open: reduced penalty (-0.10) and probe checks lag against recovery threshold.
"""
from unified_gateway import CB_HALF_OPEN_AFTER, CB_HALF_OPEN_PENALTY, CB_LAG_RECOVERY_THRESHOLD
# Put lag well below recovery threshold so the half-open probe closes the breaker
_force_obs(env, kafka_lag=500.0, rolling_p99=50.0, risk_score=10.0)
env._rolling_lag = 0.0
env._kafka_lag = 500.0
# Step CB_HALF_OPEN_AFTER times with CircuitBreaker to reach half-open
for _ in range(CB_HALF_OPEN_AFTER):
_force_obs(env, kafka_lag=500.0, rolling_p99=50.0, risk_score=10.0)
env._kafka_lag = 500.0
env.step(make_action(infra_routing=2, risk_decision=1)) # CircuitBreaker + Reject
# One more step in half-open with lag < CB_LAG_RECOVERY_THRESHOLD β breaker closes
_force_obs(env, kafka_lag=500.0, rolling_p99=50.0, risk_score=10.0)
env._kafka_lag = 500.0
_, typed_reward, _, info = env.step(make_action(infra_routing=2, risk_decision=1))
# Breaker should have closed (counter reset to 0) β bonus should be awarded
cb_steps = info.get("circuit_breaker_steps", env._cb_consecutive_steps)
# After closing, cb_consecutive_steps resets to 0
assert env._cb_consecutive_steps == 0, (
f"Breaker should have closed (cb_steps=0), got {env._cb_consecutive_steps}"
)
|